Add repo manifest
Use title cards when non provided or is livestream
This commit is contained in:
@@ -107,10 +107,19 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
/// <inheritdoc />
|
||||
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
|
||||
{
|
||||
// Could provide a channel logo here
|
||||
var assembly = GetType().Assembly;
|
||||
var resourceStream = assembly.GetManifestResourceStream("Jellyfin.Plugin.SRFPlay.Images.logo.png");
|
||||
|
||||
if (resourceStream == null)
|
||||
{
|
||||
return Task.FromResult(new DynamicImageResponse { HasImage = false });
|
||||
}
|
||||
|
||||
return Task.FromResult(new DynamicImageResponse
|
||||
{
|
||||
HasImage = false
|
||||
HasImage = true,
|
||||
Stream = resourceStream,
|
||||
Format = MediaBrowser.Model.Drawing.ImageFormat.Png
|
||||
});
|
||||
}
|
||||
|
||||
@@ -491,21 +500,36 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
// Build overview
|
||||
var overview = chapter.Description ?? chapter.Lead;
|
||||
|
||||
// Get image URL - prefer chapter image, fall back to show image if available
|
||||
var originalImageUrl = chapter.ImageUrl;
|
||||
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
|
||||
{
|
||||
originalImageUrl = mediaComposition.Show.ImageUrl;
|
||||
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
|
||||
}
|
||||
// Determine image URL based on configuration
|
||||
string? imageUrl;
|
||||
var generateTitleCards = config.GenerateTitleCards;
|
||||
|
||||
if (string.IsNullOrEmpty(originalImageUrl))
|
||||
if (generateTitleCards)
|
||||
{
|
||||
_logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title);
|
||||
// Generate title card with content name
|
||||
_logger.LogDebug("URN {Urn}: Generating title card for '{Title}'", urn, chapter.Title);
|
||||
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use SRF-provided images - prefer chapter image, fall back to show image
|
||||
var originalImageUrl = chapter.ImageUrl;
|
||||
if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null)
|
||||
{
|
||||
originalImageUrl = mediaComposition.Show.ImageUrl;
|
||||
_logger.LogDebug("URN {Urn}: Using show image as fallback", urn);
|
||||
}
|
||||
|
||||
// Proxy image URL to fix Content-Type headers from SRF CDN
|
||||
var imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
|
||||
if (string.IsNullOrEmpty(originalImageUrl))
|
||||
{
|
||||
_logger.LogDebug("URN {Urn}: No image URL available for '{Title}', using placeholder", urn, chapter.Title);
|
||||
imageUrl = CreatePlaceholderImageUrl(chapter.Title ?? "SRF Play", _mediaSourceFactory.GetServerBaseUrl());
|
||||
}
|
||||
else
|
||||
{
|
||||
imageUrl = CreateProxiedImageUrl(originalImageUrl, _mediaSourceFactory.GetServerBaseUrl());
|
||||
}
|
||||
}
|
||||
|
||||
// Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date
|
||||
var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime();
|
||||
@@ -581,6 +605,18 @@ public class SRFPlayChannel : IChannel, IHasCacheKey
|
||||
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a placeholder image URL with the given text.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display on the placeholder.</param>
|
||||
/// <param name="serverUrl">The server base URL.</param>
|
||||
/// <returns>The placeholder image URL.</returns>
|
||||
private static string CreatePlaceholderImageUrl(string text, string serverUrl)
|
||||
{
|
||||
var encodedText = Convert.ToBase64String(Encoding.UTF8.GetBytes(text));
|
||||
return $"{serverUrl}/Plugins/SRFPlay/Proxy/Placeholder?text={encodedText}";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabledFor(string userId)
|
||||
{
|
||||
|
||||
@@ -74,6 +74,7 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
EnableTrendingContent = true;
|
||||
EnableCategoryFolders = true;
|
||||
EnabledTopics = new System.Collections.Generic.List<string>();
|
||||
GenerateTitleCards = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -149,4 +150,10 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// This is important for Android and other remote clients to access streams.
|
||||
/// </summary>
|
||||
public string PublicServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to generate title card images with the content title.
|
||||
/// When enabled, generates custom thumbnails instead of using SRF-provided images.
|
||||
/// </summary>
|
||||
public bool GenerateTitleCards { get; set; }
|
||||
}
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
</label>
|
||||
<div class="fieldDescription">Automatically discover and add trending videos</div>
|
||||
</div>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input id="GenerateTitleCards" name="GenerateTitleCards" type="checkbox" is="emby-checkbox" />
|
||||
<span>Generate Title Cards</span>
|
||||
</label>
|
||||
<div class="fieldDescription">Generate custom thumbnails with the content title instead of using SRF-provided images</div>
|
||||
</div>
|
||||
<br />
|
||||
<h2>Proxy Settings</h2>
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
@@ -117,6 +124,7 @@
|
||||
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
|
||||
document.querySelector('#EnableLatestContent').checked = config.EnableLatestContent;
|
||||
document.querySelector('#EnableTrendingContent').checked = config.EnableTrendingContent;
|
||||
document.querySelector('#GenerateTitleCards').checked = config.GenerateTitleCards !== false;
|
||||
document.querySelector('#UseProxy').checked = config.UseProxy || false;
|
||||
document.querySelector('#ProxyAddress').value = config.ProxyAddress || '';
|
||||
document.querySelector('#ProxyUsername').value = config.ProxyUsername || '';
|
||||
@@ -137,6 +145,7 @@
|
||||
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value);
|
||||
config.EnableLatestContent = document.querySelector('#EnableLatestContent').checked;
|
||||
config.EnableTrendingContent = document.querySelector('#EnableTrendingContent').checked;
|
||||
config.GenerateTitleCards = document.querySelector('#GenerateTitleCards').checked;
|
||||
config.UseProxy = document.querySelector('#UseProxy').checked;
|
||||
config.ProxyAddress = document.querySelector('#ProxyAddress').value;
|
||||
config.ProxyUsername = document.querySelector('#ProxyUsername').value;
|
||||
|
||||
@@ -461,4 +461,38 @@ public class StreamProxyController : ControllerBase
|
||||
return StatusCode(StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a placeholder image with the given text centered.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display (base64 encoded).</param>
|
||||
/// <returns>A PNG image with the text centered.</returns>
|
||||
[HttpGet("Placeholder")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult GetPlaceholder([FromQuery] string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return BadRequest("Text parameter is required");
|
||||
}
|
||||
|
||||
string decodedText;
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(text);
|
||||
decodedText = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Invalid base64 text parameter: {Text}", text);
|
||||
return BadRequest("Invalid text encoding");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Generating placeholder image for: {Text}", decodedText);
|
||||
|
||||
var imageStream = PlaceholderImageGenerator.GeneratePlaceholder(decodedText);
|
||||
return File(imageStream, "image/png");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" />
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.9.11" />
|
||||
<PackageReference Include="Socks5" Version="1.1.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -27,4 +28,9 @@
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="..\assests\main logo.png" />
|
||||
<EmbeddedResource Include="..\assests\main logo.png" LogicalName="Jellyfin.Plugin.SRFPlay.Images.logo.png" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.IO;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Jellyfin.Plugin.SRFPlay.Utilities;
|
||||
|
||||
/// <summary>
|
||||
/// Generates placeholder images for content without thumbnails.
|
||||
/// </summary>
|
||||
public static class PlaceholderImageGenerator
|
||||
{
|
||||
private const int Width = 640;
|
||||
private const int Height = 360; // 16:9 aspect ratio
|
||||
|
||||
/// <summary>
|
||||
/// Generates a placeholder image with the given text centered.
|
||||
/// </summary>
|
||||
/// <param name="text">The text to display (typically channel/show name).</param>
|
||||
/// <returns>A memory stream containing the PNG image.</returns>
|
||||
public static MemoryStream GeneratePlaceholder(string text)
|
||||
{
|
||||
using var surface = SKSurface.Create(new SKImageInfo(Width, Height));
|
||||
var canvas = surface.Canvas;
|
||||
|
||||
// Dark gradient background
|
||||
using var backgroundPaint = new SKPaint();
|
||||
using var shader = SKShader.CreateLinearGradient(
|
||||
new SKPoint(0, 0),
|
||||
new SKPoint(Width, Height),
|
||||
new[] { new SKColor(45, 45, 48), new SKColor(28, 28, 30) },
|
||||
null,
|
||||
SKShaderTileMode.Clamp);
|
||||
backgroundPaint.Shader = shader;
|
||||
canvas.DrawRect(0, 0, Width, Height, backgroundPaint);
|
||||
|
||||
// Text
|
||||
using var textPaint = new SKPaint
|
||||
{
|
||||
Color = SKColors.White,
|
||||
IsAntialias = true,
|
||||
TextAlign = SKTextAlign.Center,
|
||||
Typeface = SKTypeface.FromFamilyName("Arial", SKFontStyle.Bold)
|
||||
};
|
||||
|
||||
// Calculate font size to fit text (max 48, min 24)
|
||||
float fontSize = 48;
|
||||
textPaint.TextSize = fontSize;
|
||||
var textWidth = textPaint.MeasureText(text);
|
||||
var maxWidth = Width * 0.85f;
|
||||
|
||||
if (textWidth > maxWidth)
|
||||
{
|
||||
fontSize = fontSize * maxWidth / textWidth;
|
||||
fontSize = fontSize < 24 ? 24 : fontSize;
|
||||
textPaint.TextSize = fontSize;
|
||||
}
|
||||
|
||||
// Center text vertically
|
||||
SKRect textBounds = default;
|
||||
textPaint.MeasureText(text, ref textBounds);
|
||||
var yPos = (Height / 2) - textBounds.MidY;
|
||||
|
||||
canvas.DrawText(text, Width / 2, yPos, textPaint);
|
||||
|
||||
// Encode to PNG
|
||||
using var image = surface.Snapshot();
|
||||
using var data = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
|
||||
var stream = new MemoryStream();
|
||||
data.SaveTo(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user