From 7781f67c5f16be6baa295aa946f0abcfb961b78c Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Fri, 26 Sep 2025 21:33:22 -0600 Subject: [PATCH 01/16] ExtraMetadataLoader: Work around MediaElement crash on HTTPS Steam videos (#677) MediaElement started crashing internally when opening HTTPS video URIs (Steam trailers), failing inside OpenMedia due to a null `uri2.Scheme` check. Normalizing to HTTP avoids the problematic HTTPS code path. --- .../Controls/VideoPlayerControl.xaml.cs | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs index 14ded60d20..89ddaa4285 100644 --- a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs +++ b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs @@ -547,12 +547,12 @@ public void UpdateGameVideoSources() { if (microVideoPath != null) { - VideoSource = microVideoPath; + VideoSource = NormalizeVideoUri(microVideoPath); activeVideoType = ActiveVideoType.Microtrailer; } else if (trailerVideoPath != null && SettingsModel.Settings.FallbackVideoSource) { - VideoSource = trailerVideoPath; + VideoSource = NormalizeVideoUri(trailerVideoPath); activeVideoType = ActiveVideoType.Trailer; } } @@ -560,17 +560,40 @@ public void UpdateGameVideoSources() { if (trailerVideoPath != null) { - VideoSource = trailerVideoPath; + VideoSource = NormalizeVideoUri(trailerVideoPath); activeVideoType = ActiveVideoType.Trailer; } else if (microVideoPath != null && SettingsModel.Settings.FallbackVideoSource) { - VideoSource = microVideoPath; + VideoSource = NormalizeVideoUri(microVideoPath); activeVideoType = ActiveVideoType.Microtrailer; } } } + private static Uri NormalizeVideoUri(Uri original) + { + if (original.Scheme == Uri.UriSchemeHttps) + { + // Workaround for MediaElement crash when playing Steam videos over HTTPS (#677). + // The crash occurs inside WPF’s internal OpenMedia method at: + // if (!SecurityHelper.AreStringTypesEqual(uri2.Scheme, Uri.UriSchemeHttps)) + // where `uri2` is null. + // + // Switching to HTTP avoids this code path and resolves the issue. + // This is safe in our case since Steam trailers are public/non-sensitive content. + var builder = new UriBuilder(original) + { + Scheme = Uri.UriSchemeHttp, + Port = -1 // reset to default + }; + + return builder.Uri; + } + + return original; + } + private string GetSteamId(Game game) { if (game.PluginId == Guid.Parse("cb91dfc9-b977-43bf-8e70-55f46e410fab")) From 5c7c80a41fa92de9b2c7459fc72db779634bf3e7 Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Fri, 26 Sep 2025 21:50:19 -0600 Subject: [PATCH 02/16] ExtraMetadataLoader: Catch exceptions when loading logo images and delete corrupted logo files --- .../Controls/LogoLoaderControl.xaml.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/Controls/LogoLoaderControl.xaml.cs b/source/Generic/ExtraMetadataLoader/Controls/LogoLoaderControl.xaml.cs index ab7f362d43..6319da5dac 100644 --- a/source/Generic/ExtraMetadataLoader/Controls/LogoLoaderControl.xaml.cs +++ b/source/Generic/ExtraMetadataLoader/Controls/LogoLoaderControl.xaml.cs @@ -144,11 +144,26 @@ private void UpdateLogo() return; } - var adjustedBitmap = CreateResizedBitmapImageFromPath(logoPath, Convert.ToInt32(_settings.LogoMaxWidth), Convert.ToInt32(_settings.LogoMaxHeight)); - LogoImage.Source = adjustedBitmap; - _settings.IsLogoAvailable = true; - ControlVisibility = Visibility.Visible; - StartLogoAnimation(); + try + { + var adjustedBitmap = CreateResizedBitmapImageFromPath(logoPath, Convert.ToInt32(_settings.LogoMaxWidth), Convert.ToInt32(_settings.LogoMaxHeight)); + LogoImage.Source = adjustedBitmap; + _settings.IsLogoAvailable = true; + ControlVisibility = Visibility.Visible; + StartLogoAnimation(); + } + catch (FileFormatException) + { + FileSystem.DeleteFileSafe(logoPath); + } + catch (NotSupportedException) + { + FileSystem.DeleteFileSafe(logoPath); + } + catch (Exception) + { + + } } private void StartLogoAnimation() From cd8663036a8a13fcab2fd733df4d539500cfac41 Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Fri, 26 Sep 2025 22:22:27 -0600 Subject: [PATCH 03/16] Update Magick package dependencies #672 --- .../ExtraMetadataLoader/ExtraMetadataLoader.cs | 4 ++-- .../ExtraMetadataLoader/ExtraMetadataLoader.csproj | 13 +++++++++---- source/Generic/ExtraMetadataLoader/packages.config | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs index 8b5839e7f3..faaef5e8f0 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs @@ -799,8 +799,8 @@ private bool ProcessLogoImage(string logoPath) { if (settings.Settings.MaxLogoProcessWidth < image.Width || settings.Settings.MaxLogoProcessHeight < image.Height) { - var targetWidth = settings.Settings.MaxLogoProcessWidth; - var targetHeight = settings.Settings.MaxLogoProcessHeight; + var targetWidth = (uint)settings.Settings.MaxLogoProcessWidth; + var targetHeight = (uint)settings.Settings.MaxLogoProcessHeight; MagickGeometry size = new MagickGeometry(targetWidth, targetHeight) { IgnoreAspectRatio = false diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj index 595a7f990f..29a3742522 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj @@ -33,15 +33,18 @@ prompt 4 + + x86 + ..\..\packages\AngleSharp.0.9.9\lib\net45\AngleSharp.dll - - ..\..\packages\Magick.NET-Q16-AnyCPU.8.4.0\lib\net20\Magick.NET-Q16-AnyCPU.dll + + ..\..\packages\Magick.NET-Q8-x86.14.8.2\lib\netstandard20\Magick.NET-Q8-x86.dll - - ..\..\packages\Magick.NET.Core.8.4.0\lib\net20\Magick.NET.Core.dll + + ..\..\packages\Magick.NET.Core.14.8.2\lib\netstandard20\Magick.NET.Core.dll ..\..\packages\Microsoft.Bcl.AsyncInterfaces.6.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll @@ -318,8 +321,10 @@ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + xcopy "$(ProjectDir)Localization\*.xaml" "$(TargetDir)\Localization" /Y /I /E + \ No newline at end of file diff --git a/source/Generic/ExtraMetadataLoader/packages.config b/source/Generic/ExtraMetadataLoader/packages.config index a1a8ceffa9..3178292f5b 100644 --- a/source/Generic/ExtraMetadataLoader/packages.config +++ b/source/Generic/ExtraMetadataLoader/packages.config @@ -1,8 +1,8 @@  - - + + From df1fe0171b2a40a2f0de867d60c2e0935005a522 Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Fri, 26 Sep 2025 22:28:09 -0600 Subject: [PATCH 04/16] Update eml version in manifest --- source/Generic/ExtraMetadataLoader/extension.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/extension.yaml b/source/Generic/ExtraMetadataLoader/extension.yaml index 66e299243f..20cb3174a9 100644 --- a/source/Generic/ExtraMetadataLoader/extension.yaml +++ b/source/Generic/ExtraMetadataLoader/extension.yaml @@ -1,7 +1,7 @@ Id: ExtraMetadataLoader_705fdbca-e1fc-4004-b839-1d040b8b4429 Name: Extra Metadata Loader Author: darklinkpower -Version: 1.79 +Version: 1.80 Module: ExtraMetadataLoader.dll Type: GenericPlugin Icon: icon.png @@ -12,9 +12,7 @@ Links: Url: https://github.com/darklinkpower/PlayniteExtensionsCollection/wiki/Extra-Metadata-Loader - Name: Issues Tracker Url: https://github.com/darklinkpower/PlayniteExtensionsCollection/issues?q=is%3Aopen+is%3Aissue+label%3AExtraMetadataLoader - - Name: Support (Discord) - Url: https://discord.com/channels/365863063296933888/808419165311467630 - Name: Translate Url: https://crowdin.com/project/darklinkpower-playnite-extensi - Name: Ko-fi (Tips and Donations) - Url: https://ko-fi.com/darklinkpower + Url: https://ko-fi.com/darklinkpower \ No newline at end of file From b2dc6d58856bcc906d5d4a867f76ef4165bd8e4e Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Fri, 26 Sep 2025 22:45:03 -0600 Subject: [PATCH 05/16] Backport missing fixes --- source/Common/PluginsCommon/Extensions/StringExtensions.cs | 2 +- source/Common/SteamCommon/Web.cs | 4 ++-- .../ExtraMetadataLoader/Helpers/ExtraMetadataHelper.cs | 4 ++-- .../ExtraMetadataLoader/LogoProviders/SteamGridDBProvider.cs | 4 ++-- source/Generic/ExtraMetadataLoader/extension.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/source/Common/PluginsCommon/Extensions/StringExtensions.cs b/source/Common/PluginsCommon/Extensions/StringExtensions.cs index 90b6f6adf4..20b216d6a6 100644 --- a/source/Common/PluginsCommon/Extensions/StringExtensions.cs +++ b/source/Common/PluginsCommon/Extensions/StringExtensions.cs @@ -375,7 +375,7 @@ internal static string GetPathWithoutAllExtensions(string path) return Regex.Replace(path, @"(\.[A-Za-z0-9]+)+$", ""); } - internal static string Normalize(this string str) + internal static string Satinize(this string str) { if (string.IsNullOrEmpty(str)) { diff --git a/source/Common/SteamCommon/Web.cs b/source/Common/SteamCommon/Web.cs index 022221e4d1..eead15bef5 100644 --- a/source/Common/SteamCommon/Web.cs +++ b/source/Common/SteamCommon/Web.cs @@ -30,8 +30,8 @@ public static string GetSteamIdFromSearch(string searchTerm, string steamApiCoun var results = GetSteamSearchResults(normalizedName); results.ForEach(a => a.Name = a.Name.NormalizeGameName()); - var matchingGameName = normalizedName.Normalize(); - var exactMatch = results.FirstOrDefault(x => x.Name.Normalize() == matchingGameName); + var matchingGameName = normalizedName.Satinize(); + var exactMatch = results.FirstOrDefault(x => x.Name.Satinize() == matchingGameName); if (!(exactMatch is null)) { logger.Info($"Found steam id for search {searchTerm} via steam search, Id: {exactMatch.GameId}"); diff --git a/source/Generic/ExtraMetadataLoader/Helpers/ExtraMetadataHelper.cs b/source/Generic/ExtraMetadataLoader/Helpers/ExtraMetadataHelper.cs index 6721793b20..b7ef8b687b 100644 --- a/source/Generic/ExtraMetadataLoader/Helpers/ExtraMetadataHelper.cs +++ b/source/Generic/ExtraMetadataLoader/Helpers/ExtraMetadataHelper.cs @@ -116,8 +116,8 @@ public string GetSteamIdFromSearch(Game game, bool isBackgroundDownload) } // See if there are matches by removing all symbols, spaces, etc - var matchingGameName = normalizedName.Normalize(); - var exactMatches = results.Where(x => x.Name.Normalize() == matchingGameName); + var matchingGameName = normalizedName.Satinize(); + var exactMatches = results.Where(x => x.Name.Satinize() == matchingGameName); if (exactMatches.HasItems() && (isBackgroundDownload || exactMatches.Count() == 1)) { return exactMatches.First().GameId; diff --git a/source/Generic/ExtraMetadataLoader/LogoProviders/SteamGridDBProvider.cs b/source/Generic/ExtraMetadataLoader/LogoProviders/SteamGridDBProvider.cs index 7d1a93604f..0be155e06b 100644 --- a/source/Generic/ExtraMetadataLoader/LogoProviders/SteamGridDBProvider.cs +++ b/source/Generic/ExtraMetadataLoader/LogoProviders/SteamGridDBProvider.cs @@ -116,8 +116,8 @@ private string GetSgdbRequestUrl(Game game, bool isBackgroundDownload) { var gamesList = GetSteamGridDbSearchResults(game.Name); // Try to see if there's an exact match, to not prompt the user unless needed - var matchingGameName = game.Name.Normalize(); - var exactMatches = gamesList.Where(x => x.Name.Normalize() == matchingGameName); + var matchingGameName = game.Name.Satinize(); + var exactMatches = gamesList.Where(x => x.Name.Satinize() == matchingGameName); if (isBackgroundDownload) { if (exactMatches?.ToList().Count > 0) diff --git a/source/Generic/ExtraMetadataLoader/extension.yaml b/source/Generic/ExtraMetadataLoader/extension.yaml index 20cb3174a9..ac0f87b7d0 100644 --- a/source/Generic/ExtraMetadataLoader/extension.yaml +++ b/source/Generic/ExtraMetadataLoader/extension.yaml @@ -1,7 +1,7 @@ Id: ExtraMetadataLoader_705fdbca-e1fc-4004-b839-1d040b8b4429 Name: Extra Metadata Loader Author: darklinkpower -Version: 1.80 +Version: 1.81 Module: ExtraMetadataLoader.dll Type: GenericPlugin Icon: icon.png From 0234ff052e8a772cf0d247478cacea3049e58792 Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Thu, 2 Oct 2025 12:13:41 -0600 Subject: [PATCH 06/16] SteamCommon: Backport Steam Web search fixes --- source/Common/SteamCommon/Web.cs | 60 ++++++++++++------- .../ExtraMetadataLoader/extension.yaml | 2 +- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/source/Common/SteamCommon/Web.cs b/source/Common/SteamCommon/Web.cs index eead15bef5..1724ba90b3 100644 --- a/source/Common/SteamCommon/Web.cs +++ b/source/Common/SteamCommon/Web.cs @@ -60,9 +60,14 @@ public static List GetSteamSearchResults(string searchTerm, s } // Game Data - var title = gameElem.QuerySelector(".title").InnerHtml; - var releaseDate = gameElem.QuerySelector(".search_released").InnerHtml; var gameId = gameElem.GetAttribute("data-ds-appid"); + if (gameId.IsNullOrEmpty()) + { + continue; + } + + var title = gameElem.QuerySelector(".title")?.InnerHtml ?? string.Empty; + var releaseDate = gameElem.QuerySelector(".search_released")?.InnerHtml ?? string.Empty; // Prices Data var discountPercentage = 0; @@ -74,18 +79,21 @@ public static List GetSteamSearchResults(string searchTerm, s var isFree = false; var priceData = gameElem.QuerySelector(".search_discount_and_price"); - if (!priceData.InnerHtml.IsNullOrWhiteSpace()) + if (priceData != null && !priceData.InnerHtml.IsNullOrWhiteSpace()) { // Game has pricing data var discountBlock = priceData.QuerySelector(".discount_block"); - if (discountBlock.HasAttribute("data-discount")) + if (discountBlock != null) { - discountPercentage = int.Parse(discountBlock.GetAttribute("data-discount")); - } - - if (discountBlock.HasAttribute("data-price-final")) - { - priceFinal = int.Parse(discountBlock.GetAttribute("data-price-final")) * 0.01; + if (discountBlock.HasAttribute("data-discount")) + { + discountPercentage = int.Parse(discountBlock.GetAttribute("data-discount")); + } + + if (discountBlock.HasAttribute("data-price-final")) + { + priceFinal = int.Parse(discountBlock.GetAttribute("data-price-final")) * 0.01; + } } priceOriginal = GetSearchOriginalPrice(priceFinal, discountPercentage); @@ -95,7 +103,9 @@ public static List GetSteamSearchResults(string searchTerm, s //Urls var storeUrl = gameElem.GetAttribute("href"); - var capsuleUrl = gameElem.QuerySelector(".search_capsule").Children[0].GetAttribute("src"); + var capsuleUrl = gameElem.QuerySelector(".search_capsule")? + .Children.FirstOrDefault()? + .GetAttribute("src"); results.Add(new StoreSearchResult { @@ -119,31 +129,35 @@ public static List GetSteamSearchResults(string searchTerm, s return results; } - private static void GetCurrencyFromSearchPriceDiv(AngleSharp.Dom.IElement priceBlock, out string currency, out bool isReleased, out bool isFree) + private static void GetCurrencyFromSearchPriceDiv( + AngleSharp.Dom.IElement priceBlock, + out string currency, + out bool isReleased, + out bool isFree) { - currency = GetCurrencyFromPriceString(priceBlock.QuerySelector(".discount_final_price").InnerHtml); - var noDiscount = priceBlock.QuerySelector(".search_discount_block no_discount"); + isReleased = false; + isFree = false; + + var priceEl = priceBlock.QuerySelector(".discount_final_price"); + currency = priceEl != null ? GetCurrencyFromPriceString(priceEl.InnerHtml) : null; + + var noDiscount = priceBlock.QuerySelector(".search_discount_block.no_discount"); if (noDiscount != null) { // Non discounted item isReleased = true; - isFree = currency == null; + isFree = currency.IsNullOrEmpty(); return; } var discountDiv = priceBlock.QuerySelector(".search_discount_block"); - if (discountDiv != null) + if (discountDiv != null && !discountDiv.InnerHtml.IsNullOrEmpty()) { - // Non discounted item + // Discounted item isReleased = true; - isFree = currency == null; + isFree = currency.IsNullOrEmpty(); return; } - - isReleased = false; - currency = null; - isFree = false; - return; } private static string GetCurrencyFromPriceString(string priceString) diff --git a/source/Generic/ExtraMetadataLoader/extension.yaml b/source/Generic/ExtraMetadataLoader/extension.yaml index ac0f87b7d0..ebeb734565 100644 --- a/source/Generic/ExtraMetadataLoader/extension.yaml +++ b/source/Generic/ExtraMetadataLoader/extension.yaml @@ -1,7 +1,7 @@ Id: ExtraMetadataLoader_705fdbca-e1fc-4004-b839-1d040b8b4429 Name: Extra Metadata Loader Author: darklinkpower -Version: 1.81 +Version: 1.82 Module: ExtraMetadataLoader.dll Type: GenericPlugin Icon: icon.png From e98a99b5db41ac21d6781571bce5ff59f2dba9ba Mon Sep 17 00:00:00 2001 From: supersmo Date: Sat, 13 Dec 2025 16:55:36 +0100 Subject: [PATCH 07/16] ExtraMetadataLoader: Guess Steam mp4 trailer url if not found (#707) --- .../Controls/VideoPlayerControl.xaml.cs | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs index 89ddaa4285..891d2c0a85 100644 --- a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs +++ b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs @@ -20,6 +20,7 @@ using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Threading; +using System.Net.Http; namespace ExtraMetadataLoader { @@ -514,16 +515,55 @@ public void UpdateGameVideoSources() { if (trailerVideoPath == null) { - if (SettingsModel.Settings.StreamSteamHighQuality) + var movie = response.data?.Movies?.FirstOrDefault(); + if (movie?.Mp4 != null) { - trailerVideoPath = response.data.Movies?[0].Mp4.Max; + // choose video quality + var urlString = (SettingsModel.Settings.StreamSteamHighQuality ? movie.Mp4.Max : movie.Mp4.Q480)?.ToString(); + if (!string.IsNullOrEmpty(urlString)) + { + try + { + trailerVideoPath = new Uri(urlString); + SettingsModel.Settings.IsAnyVideoAvailable = true; + SettingsModel.Settings.IsTrailerAvailable = true; + } + catch (UriFormatException) + { + logger.Error($"Error forming Steam trailer video url: {urlString}"); + } + } } - else + // attempt to guess the video url based on known pattern + else if (movie?.Id != null) { - trailerVideoPath = response.data.Movies?[0].Mp4.Q480; + // choose guessed filename based on StreamSteamHighQuality setting + var guessedFile = SettingsModel.Settings.StreamSteamHighQuality ? "movie_max.mp4" : "movie480.mp4"; + var guessedUrl = $"https://video.akamai.steamstatic.com/store_trailers/{movie.Id}/{guessedFile}"; + try + { + var uri = new Uri(guessedUrl); + using (var client = new HttpClient()) + { + var request = new HttpRequestMessage(HttpMethod.Head, uri); + var httpResponse = client.SendAsync(request).Result; + if (httpResponse.IsSuccessStatusCode) + { + trailerVideoPath = uri; + SettingsModel.Settings.IsAnyVideoAvailable = true; + SettingsModel.Settings.IsTrailerAvailable = true; + } + } + } + catch (UriFormatException) + { + logger.Error($"Error forming guessed Steam trailer video url: {guessedUrl}"); + } + catch (HttpRequestException) + { + logger.Info($"Guessed Steam trailer not available: {guessedUrl}"); + } } - SettingsModel.Settings.IsAnyVideoAvailable = true; - SettingsModel.Settings.IsTrailerAvailable = true; } } } From ad9f6d515f3d4be0611d0c4a01a7118bdd1d1f12 Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Sat, 13 Dec 2025 10:43:57 -0600 Subject: [PATCH 08/16] ExtraMetadataLoader: Fix Steam downloads broken due to Steam now only providing Streamable formats #705 #684 --- .../Models/SteamAppDetailsResponse.cs | 24 +++++---- .../Controls/VideoPlayerControl.xaml.cs | 23 +-------- .../Services/VideosDownloader.cs | 51 +++++++++++++++---- 3 files changed, 58 insertions(+), 40 deletions(-) diff --git a/source/Common/SteamCommon/Models/SteamAppDetailsResponse.cs b/source/Common/SteamCommon/Models/SteamAppDetailsResponse.cs index 21724a132c..350c8230d8 100644 --- a/source/Common/SteamCommon/Models/SteamAppDetailsResponse.cs +++ b/source/Common/SteamCommon/Models/SteamAppDetailsResponse.cs @@ -1,4 +1,5 @@ -using Playnite.SDK; +using Newtonsoft.Json; +using Playnite.SDK; using Playnite.SDK.Data; using System; using System.Collections.Generic; @@ -73,22 +74,25 @@ public class Screenshot public class Movie { - [SerializationPropertyName("id")] - public uint Id { get; set; } + [JsonProperty("id")] + public long Id { get; set; } - [SerializationPropertyName("name")] + [JsonProperty("name")] public string Name { get; set; } - [SerializationPropertyName("thumbnail")] + [JsonProperty("thumbnail")] public Uri Thumbnail { get; set; } - [SerializationPropertyName("webm")] - public Mp4 Webm { get; set; } + [JsonProperty("dash_av1")] + public Uri DashAv1 { get; set; } - [SerializationPropertyName("mp4")] - public Mp4 Mp4 { get; set; } + [JsonProperty("dash_h264")] + public Uri DashH264 { get; set; } - [SerializationPropertyName("highlight")] + [JsonProperty("hls_h264")] + public Uri HlsH264 { get; set; } + + [JsonProperty("highlight")] public bool Highlight { get; set; } } diff --git a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs index 891d2c0a85..ba98018ffb 100644 --- a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs +++ b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml.cs @@ -513,29 +513,10 @@ public void UpdateGameVideoSources() var response = parsedData[parsedData.Keys.First()]; if (response.success == true && response.data != null) { - if (trailerVideoPath == null) + if (trailerVideoPath is null) { var movie = response.data?.Movies?.FirstOrDefault(); - if (movie?.Mp4 != null) - { - // choose video quality - var urlString = (SettingsModel.Settings.StreamSteamHighQuality ? movie.Mp4.Max : movie.Mp4.Q480)?.ToString(); - if (!string.IsNullOrEmpty(urlString)) - { - try - { - trailerVideoPath = new Uri(urlString); - SettingsModel.Settings.IsAnyVideoAvailable = true; - SettingsModel.Settings.IsTrailerAvailable = true; - } - catch (UriFormatException) - { - logger.Error($"Error forming Steam trailer video url: {urlString}"); - } - } - } - // attempt to guess the video url based on known pattern - else if (movie?.Id != null) + if (movie?.Id != null) { // choose guessed filename based on StreamSteamHighQuality setting var guessedFile = SettingsModel.Settings.StreamSteamHighQuality ? "movie_max.mp4" : "movie480.mp4"; diff --git a/source/Generic/ExtraMetadataLoader/Services/VideosDownloader.cs b/source/Generic/ExtraMetadataLoader/Services/VideosDownloader.cs index 121716a020..a6ff16858e 100644 --- a/source/Generic/ExtraMetadataLoader/Services/VideosDownloader.cs +++ b/source/Generic/ExtraMetadataLoader/Services/VideosDownloader.cs @@ -37,6 +37,12 @@ public VideosDownloader(IPlayniteAPI playniteApi, ExtraMetadataLoaderSettings se public bool DownloadSteamVideo(Game game, bool overwrite, bool isBackgroundDownload, CancellationToken cancelToken, bool downloadVideo = false, bool downloadVideoMicro = false) { logger.Debug($"DownloadSteamVideo starting for game {game.Name}"); + var ffmpegPath = settings.FfmpegPath; + if (ffmpegPath.IsNullOrWhiteSpace() || !File.Exists(ffmpegPath)) + { + return false; + } + var videoPath = extraMetadataHelper.GetGameVideoPath(game, true); var videoMicroPath = extraMetadataHelper.GetGameVideoMicroPath(game, true); if (FileSystem.FileExists(videoPath) && !overwrite) @@ -80,22 +86,49 @@ public bool DownloadSteamVideo(Game game, bool overwrite, bool isBackgroundDownl return false; } + + if (downloadVideo) { - var videoUrl = steamAppDetails.data.Movies[0].Mp4.Q480; - if (settings.VideoSteamDownloadHdQuality) + var movie = steamAppDetails.data.Movies[0]; + + Uri videoManifestUrl = + movie.DashH264 ?? + movie.HlsH264 ?? + null; + + if (videoManifestUrl is null) { - videoUrl = steamAppDetails.data.Movies[0].Mp4.Max; + return false; } - var downloadFileResult = HttpRequestFactory.GetHttpFileRequest() - .WithUrl(videoUrl.ToString()).WithDownloadTo(tempDownloadPath) - .DownloadFile(cancelToken); - if (downloadFileResult.IsSuccess) + int videoStreamIndex = settings.VideoSteamDownloadHdQuality + ? 0 // 1080p + : 1; // 720p + + var ffmpegArgs = + "-y -loglevel error " + + "-analyzeduration 0 -probesize 32k " + + $"-i \"{videoManifestUrl}\" " + + $"-map 0:v:{videoStreamIndex}? " + + "-map 0:a:0? " + + "-c copy -movflags +faststart " + + $"\"{tempDownloadPath}\""; + + var ffmpegResult = ProcessStarter.StartProcessWait(ffmpegPath, ffmpegArgs, Path.GetDirectoryName(ffmpegPath), true, out var stdOut, out var stdErr); + if (ffmpegResult != 0) { - GetVideoInformation(tempDownloadPath); - ProcessVideo(tempDownloadPath, videoPath, false, true); + logger.Error($"Failed to download video in ffmpeg: {videoPath}, {ffmpegResult}, {stdErr}"); + playniteApi.Notifications.Add(new NotificationMessage( + Guid.NewGuid().ToString(), + string.Format(ResourceProvider.GetString("LOCExtra_Metadata_Loader_NotificationErrorFfmpegProcessingFail"), videoPath, ffmpegResult, stdErr), + NotificationType.Error) + ); + + return false; } + + ProcessVideo(tempDownloadPath, videoPath, false, true); } if (downloadVideoMicro) { From 141fc4d657c2b15b2ff3ad5b2657102371d41a4c Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Sat, 13 Dec 2025 10:48:46 -0600 Subject: [PATCH 09/16] EML v1.83 --- source/Generic/ExtraMetadataLoader/extension.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/Generic/ExtraMetadataLoader/extension.yaml b/source/Generic/ExtraMetadataLoader/extension.yaml index ebeb734565..ab812a8ef6 100644 --- a/source/Generic/ExtraMetadataLoader/extension.yaml +++ b/source/Generic/ExtraMetadataLoader/extension.yaml @@ -1,7 +1,7 @@ Id: ExtraMetadataLoader_705fdbca-e1fc-4004-b839-1d040b8b4429 Name: Extra Metadata Loader Author: darklinkpower -Version: 1.82 +Version: 1.83 Module: ExtraMetadataLoader.dll Type: GenericPlugin Icon: icon.png From 48f2ba29fc9d5461eaeb5eaafb82a3b54105f7ff Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Thu, 18 Dec 2025 15:03:57 -0600 Subject: [PATCH 10/16] ExtraMetadataLoader: Update Magick component to updated Playnite version to fix logo processing issues #710 --- source/Generic/ExtraMetadataLoader/packages.config | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/packages.config b/source/Generic/ExtraMetadataLoader/packages.config index 3178292f5b..87e6a4dc6e 100644 --- a/source/Generic/ExtraMetadataLoader/packages.config +++ b/source/Generic/ExtraMetadataLoader/packages.config @@ -1,8 +1,8 @@  - - + + From 2f92768ac4a6681e2d48ee99f83e5fc8fde37f6b Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Thu, 18 Dec 2025 15:04:52 -0600 Subject: [PATCH 11/16] ExtraMetadataLoader: Add try catch clauses to logo processing --- .../ExtraMetadataLoader/ExtraMetadataLoader.cs | 18 ++++++++++++++++-- .../ExtraMetadataLoader.csproj | 12 ++++++------ .../Generic/ExtraMetadataLoader/extension.yaml | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs index faaef5e8f0..e6404e8075 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.cs @@ -429,7 +429,14 @@ public override IEnumerable GetGameMenuItems(GetGameMenuItemsArgs var fileCopied = FileSystem.CopyFile(filePath, logoPath, true); if (settings.Settings.ProcessLogosOnDownload && fileCopied) { - ProcessLogoImage(logoPath); + try + { + ProcessLogoImage(logoPath); + } + catch (Exception e) + { + logger.Error(e, $"Error processing logo {logoPath}"); + } } OnLogoUpdated(game); @@ -766,7 +773,14 @@ private bool GetGameLogo(ILogoProvider logoProvider, Game game, bool isBackgroun { if (settings.Settings.ProcessLogosOnDownload) { - ProcessLogoImage(logoPath); + try + { + ProcessLogoImage(logoPath); + } + catch (Exception e) + { + logger.Error(e, $"Error processing logo {logoPath}"); + } } OnLogoUpdated(game); diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj index 29a3742522..c1f28c2992 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj @@ -40,11 +40,11 @@ ..\..\packages\AngleSharp.0.9.9\lib\net45\AngleSharp.dll - - ..\..\packages\Magick.NET-Q8-x86.14.8.2\lib\netstandard20\Magick.NET-Q8-x86.dll + + ..\..\packages\Magick.NET-Q8-x86.14.10.0\lib\netstandard20\Magick.NET-Q8-x86.dll - - ..\..\packages\Magick.NET.Core.14.8.2\lib\netstandard20\Magick.NET.Core.dll + + ..\..\packages\Magick.NET.Core.14.10.0\lib\netstandard20\Magick.NET.Core.dll ..\..\packages\Microsoft.Bcl.AsyncInterfaces.6.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll @@ -321,10 +321,10 @@ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - + xcopy "$(ProjectDir)Localization\*.xaml" "$(TargetDir)\Localization" /Y /I /E - + \ No newline at end of file diff --git a/source/Generic/ExtraMetadataLoader/extension.yaml b/source/Generic/ExtraMetadataLoader/extension.yaml index ab812a8ef6..d723a8ec00 100644 --- a/source/Generic/ExtraMetadataLoader/extension.yaml +++ b/source/Generic/ExtraMetadataLoader/extension.yaml @@ -1,7 +1,7 @@ Id: ExtraMetadataLoader_705fdbca-e1fc-4004-b839-1d040b8b4429 Name: Extra Metadata Loader Author: darklinkpower -Version: 1.83 +Version: 1.84 Module: ExtraMetadataLoader.dll Type: GenericPlugin Icon: icon.png From 84d76a73710c36d90d37bf3a3a3ac6b145fb386d Mon Sep 17 00:00:00 2001 From: darklinkpower Date: Thu, 5 Feb 2026 12:50:15 -0600 Subject: [PATCH 12/16] Update Magick to 14.10.2 --- .../ExtraMetadataLoader/ExtraMetadataLoader.csproj | 8 ++++---- source/Generic/ExtraMetadataLoader/extension.yaml | 2 +- source/Generic/ExtraMetadataLoader/packages.config | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj index c1f28c2992..25f9189591 100644 --- a/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj +++ b/source/Generic/ExtraMetadataLoader/ExtraMetadataLoader.csproj @@ -41,10 +41,10 @@ ..\..\packages\AngleSharp.0.9.9\lib\net45\AngleSharp.dll - ..\..\packages\Magick.NET-Q8-x86.14.10.0\lib\netstandard20\Magick.NET-Q8-x86.dll + ..\..\packages\Magick.NET-Q8-x86.14.10.2\lib\netstandard20\Magick.NET-Q8-x86.dll - ..\..\packages\Magick.NET.Core.14.10.0\lib\netstandard20\Magick.NET.Core.dll + ..\..\packages\Magick.NET.Core.14.10.2\lib\netstandard20\Magick.NET.Core.dll ..\..\packages\Microsoft.Bcl.AsyncInterfaces.6.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll @@ -321,10 +321,10 @@ This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - + xcopy "$(ProjectDir)Localization\*.xaml" "$(TargetDir)\Localization" /Y /I /E - + \ No newline at end of file diff --git a/source/Generic/ExtraMetadataLoader/extension.yaml b/source/Generic/ExtraMetadataLoader/extension.yaml index d723a8ec00..73b7efc3c6 100644 --- a/source/Generic/ExtraMetadataLoader/extension.yaml +++ b/source/Generic/ExtraMetadataLoader/extension.yaml @@ -1,7 +1,7 @@ Id: ExtraMetadataLoader_705fdbca-e1fc-4004-b839-1d040b8b4429 Name: Extra Metadata Loader Author: darklinkpower -Version: 1.84 +Version: 1.85 Module: ExtraMetadataLoader.dll Type: GenericPlugin Icon: icon.png diff --git a/source/Generic/ExtraMetadataLoader/packages.config b/source/Generic/ExtraMetadataLoader/packages.config index 87e6a4dc6e..25f08de797 100644 --- a/source/Generic/ExtraMetadataLoader/packages.config +++ b/source/Generic/ExtraMetadataLoader/packages.config @@ -1,8 +1,8 @@  - - + + From 1ccfb9bdb42741282604a60a982a1739b21dee67 Mon Sep 17 00:00:00 2001 From: Dawnflare <129705403+Dawnflare@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:19:15 -0700 Subject: [PATCH 13/16] feat(eml): Fullscreen borderless video player with premium controls (#732) * feat(video): add premium fullscreen transport controls * fix(video): prevent transport controls from stealing spacebar focus * docs: describe porting to stable branch and spacebar focus fix * chore: add .agent to gitignore and remove from tracking --- .gitignore | 5 +- documentation/Readme-FullScreenVideo.md | 64 ++++ .../Controls/FullscreenVideoWindow.xaml | 166 +++++++++ .../Controls/FullscreenVideoWindow.xaml.cs | 346 ++++++++++++++++++ .../Controls/VideoPlayerControl.xaml | 13 +- .../Controls/VideoPlayerControl.xaml.cs | 77 +++- .../ExtraMetadataLoader.csproj | 9 +- 7 files changed, 675 insertions(+), 5 deletions(-) create mode 100644 documentation/Readme-FullScreenVideo.md create mode 100644 source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml create mode 100644 source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml.cs diff --git a/.gitignore b/.gitignore index 3629fd0821..1a33706645 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,7 @@ _ReSharper*/ packages/ # Ignore everything else in release folders -**/bin/[Rr]elease*/ \ No newline at end of file +**/bin/[Rr]elease*/ + +# Ignore Antigravity agent folders +.agent/ \ No newline at end of file diff --git a/documentation/Readme-FullScreenVideo.md b/documentation/Readme-FullScreenVideo.md new file mode 100644 index 0000000000..4f255e7cf5 --- /dev/null +++ b/documentation/Readme-FullScreenVideo.md @@ -0,0 +1,64 @@ +# Fullscreen Video Capability - Extra Metadata Loader + +This update adds the ability to view game videos in a borderless, maximized fullscreen window within the Extra Metadata Loader extension for Playnite. + +## ✨ Features + +- **Multiple Triggers**: + - Click the new **Fullscreen (⛶)** button in the embedded video player's control bar. + - **Double-click** anywhere on the video surface to rapidly pop it out. +- **State Preservation**: A completely seamless handoff. The video continues from its exact current position, volume level, and muted/unmuted state when switching between embedded and fullscreen modes. +- **Animated Transport Controls**: + - A bottom-aligned control bar containing full premium features. + - **Animated Opacity**: The entire control bar, as well as the exit button, rests at an unobtrusive 15% opacity so it doesn't distract from the video. Hovering immediately triggers a smooth fade-in to 90% opacity (200ms in, 400ms out). + - **Play/Pause Toggle**: Features a dedicated toggle button, but can also be triggered by hitting the **Spacebar** or with a **Single-click** anywhere on the video surface. + - **Timeline Slider**: A scrubbable timeline slider with live timestamp updates relative to the total duration. + - **Volume & Mute Controls**: Includes a slider with a perceptually accurate (linear to quadratic) curve, a dedicated mute toggle button, and is also mapped to the **M key**. +- **Exit Methods**: + - Press the **Escape** key. + - **Double-click** the fullscreen video. + - Click the **✕** overlay button in the top-right corner. +- **Auto-Looping**: Respects the "Repeat trailer videos" setting from the plugin configuration. + +## 🛠️ Technical Implementation & Bug Fixes + +This feature has been surgically ported to the stable `emlCrashFix2025` branch, avoiding regressions in the `master` repository (such as the broken Steam Video downloader). It includes robust fixes to the WPF `MediaElement` implementation: +- **Spacebar Focus Fix**: Explicitly set `Focusable="False"` on all interactive control buttons and sliders in the fullscreen window. This prevents mouse clicks from stealing keyboard focus, ensuring the Spacebar consistently acts as a global Play/Pause toggle rather than re-triggering the last clicked button. +- **Black Screen on Pause resolved**: When entering fullscreen while a video is paused, WPF natively fails to render the initial frame, presenting a black screen. A robust `fsPlayer.Pause()` injection during initialization forces the pipeline to immediately render the initial start position frame. +- **Stream Reset fixed**: Prevented an aggressive WPF bug that resets a manual stream back to `00:00` the very first time `Play()` is called from a Paused state. +- **Mute Syncing**: Fixed logic where the fullscreen player would ignore the embedded player's mute state. +- **Compilation & Integration**: Cleanly injected the new windows into the existing `ExtraMetadataLoader.csproj` structure, ensuring zero compilation errors on the `emlCrashFix2025` foundation while adhering to the original author's strict coding guidelines (PascalCase, camelCase with underscores, etc.). + +## 🧪 Installation & Testing Instructions + +### 1. Build and Import + +To test these changes, you need to compile the project and manually replace the extension files in your Playnite installation. + +1. **Build the Project**: + Open a terminal in the project root and run: + ```powershell + msbuild source\Generic\ExtraMetadataLoader\ExtraMetadataLoader.csproj /p:Configuration=Debug /t:Build + ``` + This will produce `ExtraMetadataLoader.dll` in `source\Generic\ExtraMetadataLoader\bin\Debug\`. + +2. **Locate Playnite Extensions**: + Open Playnite, go to `Main Menu > About Playnite > User data directory`. + Navigate to the `Extensions` folder (**not** `ExtensionsData`). + Look for a folder named `ExtraMetadataLoader` or `705fdbca-e1fc-4004-b839-1d040b8b4429` (the Extra Metadata Loader GUID). + +3. **Replace Files**: + - **Close Playnite** completely. + - Copy the newly built `ExtraMetadataLoader.dll` from your build output to the extensions folder, overwriting the existing one. + - Ensure the `Localization` and `Controls` folders (if applicable) are also synced if you made XAML changes that aren't embedded. + +### 2. Verification Checklist + +Follow these steps to verify the feature: + +1. **Launch Playnite**: Open a game that has a video trailer. +2. **Toggle Button**: Hover over the video to reveal the control bar. Click the ⛶ button. The video should pop into fullscreen. +3. **Double-Click**: Exit fullscreen, then double-click the video surface. It should enter fullscreen. +4. **Exit Triggers**: While in fullscreen, verify that **Escape**, **Double-clicking**, and the **top-right X button** all return you to the Playnite interface. +5. **State Restore**: Pause a video at `0:10`, enter fullscreen. It should be paused at `0:10`. Play it to `0:15`, exit fullscreen. It should be playing at `0:15` in the embedded player. +6. **Volume & Mute Sync**: Mute the video in Playnite. Enter fullscreen. The video should be muted. Change the volume and un-mute in the fullscreen controls, hit Escape, and verify those changes persisted back to the embedded player. diff --git a/source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml b/source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml new file mode 100644 index 0000000000..82264f5997 --- /dev/null +++ b/source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml.cs b/source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml.cs new file mode 100644 index 0000000000..7fd49c68d0 --- /dev/null +++ b/source/Generic/ExtraMetadataLoader/Controls/FullscreenVideoWindow.xaml.cs @@ -0,0 +1,346 @@ +using Playnite.SDK; +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Threading; + +namespace EmlFullscreen +{ + /// + /// Fullscreen video playback window with transport controls. + /// Spawned by VideoPlayerControl to display video trailers + /// in a borderless, maximized window. + /// + public partial class FullscreenVideoWindow : Window + { + private static readonly ILogger _logger = LogManager.GetLogger(); + + private readonly TimeSpan _startPosition; + private readonly bool _startPlaying; + private readonly bool _shouldLoop; + private bool _hasAppliedStartPosition; + private bool _isDragging; + private bool _isMuted; + private double _volumeBeforeMute; + private readonly DispatcherTimer _timer; + + /// + /// The playback position at the time the window was closed. + /// + public TimeSpan ExitPosition { get; private set; } + + /// + /// Whether the video was actively playing when the window was closed. + /// + public bool WasPlaying { get; private set; } + + /// + /// The volume level at the time the window was closed. + /// + public double ExitVolume { get; private set; } + + /// + /// Whether the player was muted when the window was closed. + /// + public bool ExitMuted { get; private set; } + + /// + /// Creates and initializes the fullscreen video window. + /// + /// Video file URI to play. + /// Position to seek to after media opens. + /// Volume level (0.0 to 1.0). + /// Whether to begin playback immediately. + /// Whether the video should loop on completion. + /// Whether the player should start muted. + public FullscreenVideoWindow(Uri source, TimeSpan startPosition, double volume, bool startPlaying, bool shouldLoop, bool isMuted) + { + InitializeComponent(); + + _startPosition = startPosition; + _startPlaying = startPlaying; + _shouldLoop = shouldLoop; + _hasAppliedStartPosition = false; + _isDragging = false; + _isMuted = isMuted; + _volumeBeforeMute = volume; + + // Set up the volume slider and player volume + VolumeSlider.Value = Math.Sqrt(volume); // Convert quadratic to linear for slider + if (_isMuted) + { + fsPlayer.Volume = 0; + MuteIcon.Text = "\uE74F"; // Muted icon + } + else + { + fsPlayer.Volume = volume; + MuteIcon.Text = "\uE767"; // Unmuted icon + } + + // Set up the timeline update timer + _timer = new DispatcherTimer(); + _timer.Interval = TimeSpan.FromMilliseconds(250); + _timer.Tick += Timer_Tick; + + try + { + fsPlayer.Source = source; + + if (_startPlaying) + { + fsPlayer.Play(); + WasPlaying = true; + PlayPauseIcon.Text = "\uE769"; // Pause icon + _timer.Start(); + } + else + { + // FIX: Explicitly enter Paused state so WPF renders the initial frame + // instead of a black screen. (Requires ScrubbingEnabled="True" in XAML) + fsPlayer.Pause(); + PlayPauseIcon.Text = "\uE768"; // Play icon + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to initialize fullscreen video source."); + ExitPosition = startPosition; + WasPlaying = false; + ExitVolume = volume; + ExitMuted = isMuted; + Close(); + } + } + + private void Timer_Tick(object sender, EventArgs e) + { + if (!_isDragging) + { + TimelineSlider.Value = fsPlayer.Position.TotalSeconds; + } + + UpdateTimeDisplay(); + } + + private void UpdateTimeDisplay() + { + var current = fsPlayer.Position.ToString(@"mm\:ss") ?? "00:00"; + var total = fsPlayer.NaturalDuration.HasTimeSpan + ? fsPlayer.NaturalDuration.TimeSpan.ToString(@"mm\:ss") + : "00:00"; + TimeDisplay.Text = $"{current} / {total}"; + } + + private void FsPlayer_MediaOpened(object sender, RoutedEventArgs e) + { + // Seek to the start position once the media is loaded. + if (!_hasAppliedStartPosition) + { + _hasAppliedStartPosition = true; + fsPlayer.Position = _startPosition; + } + + // Configure the timeline slider range + if (fsPlayer.NaturalDuration.HasTimeSpan) + { + var ts = fsPlayer.NaturalDuration.TimeSpan; + TimelineSlider.Maximum = ts.TotalSeconds; + TimelineSlider.SmallChange = 0.25; + TimelineSlider.LargeChange = Math.Min(10, ts.TotalSeconds / 10); + } + + UpdateTimeDisplay(); + } + + private void FsPlayer_MediaEnded(object sender, RoutedEventArgs e) + { + if (_shouldLoop) + { + fsPlayer.Position = TimeSpan.Zero; + fsPlayer.Play(); + } + else + { + WasPlaying = false; + PlayPauseIcon.Text = "\uE768"; // Play icon + _timer.Stop(); + } + } + + // ── Play/Pause ────────────────────────────────────────── + + private void TogglePlayPause() + { + if (WasPlaying) + { + fsPlayer.Pause(); + WasPlaying = false; + PlayPauseIcon.Text = "\uE768"; // Play icon + _timer.Stop(); + } + else + { + var currentPos = fsPlayer.Position; + fsPlayer.Play(); + + // FIX: WPF MediaElement may reset the internal stream to 00:00 when Play() + // is called for the first time after it was loaded in a Paused state. + // Reapplying the previously known valid position immediately after calling Play() prevents this jump. + if (currentPos != TimeSpan.Zero) + { + fsPlayer.Position = currentPos; + } + + WasPlaying = true; + PlayPauseIcon.Text = "\uE769"; // Pause icon + _timer.Start(); + } + } + + private void PlayPauseButton_Click(object sender, RoutedEventArgs e) + { + TogglePlayPause(); + } + + // ── Mute ──────────────────────────────────────────────── + + private void ToggleMute() + { + _isMuted = !_isMuted; + if (_isMuted) + { + _volumeBeforeMute = fsPlayer.Volume; + fsPlayer.Volume = 0; + MuteIcon.Text = "\uE74F"; // Muted icon + } + else + { + fsPlayer.Volume = _volumeBeforeMute; + MuteIcon.Text = "\uE767"; // Unmuted icon + } + } + + private void MuteButton_Click(object sender, RoutedEventArgs e) + { + ToggleMute(); + } + + // ── Volume Slider ─────────────────────────────────────── + + private void VolumeSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + // Convert linear slider value to quadratic for perceptual volume + var linearValue = VolumeSlider.Value; + var quadraticVolume = linearValue * linearValue; + + if (!_isMuted) + { + fsPlayer.Volume = quadraticVolume; + } + + _volumeBeforeMute = quadraticVolume; + } + + // ── Timeline Slider ───────────────────────────────────── + + private void TimelineSlider_DragStarted(object sender, DragStartedEventArgs e) + { + _isDragging = true; + } + + private void TimelineSlider_DragCompleted(object sender, DragCompletedEventArgs e) + { + _isDragging = false; + fsPlayer.Position = TimeSpan.FromSeconds(TimelineSlider.Value); + } + + private void TimelineSlider_PreviewMouseUp(object sender, MouseButtonEventArgs e) + { + if (!_isDragging) + { + var delta = e.GetPosition(TimelineSlider).X / TimelineSlider.ActualWidth; + if (fsPlayer.NaturalDuration.HasTimeSpan) + { + fsPlayer.Position = TimeSpan.FromSeconds(TimelineSlider.Maximum * delta); + } + } + } + + // ── Keyboard & Mouse ──────────────────────────────────── + + private void Window_KeyDown(object sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + CloseFullscreen(); + } + else if (e.Key == Key.Space) + { + TogglePlayPause(); + e.Handled = true; + } + else if (e.Key == Key.M) + { + ToggleMute(); + e.Handled = true; + } + } + + private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ClickCount == 1) + { + TogglePlayPause(); + } + } + + private void Window_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + CloseFullscreen(); + } + + /// + /// Prevents clicks on the control bar from bubbling up + /// to the window and triggering play/pause toggle. + /// + private void ControlBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + e.Handled = true; + } + + // ── Exit ──────────────────────────────────────────────── + + private void ExitButton_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + e.Handled = true; + CloseFullscreen(); + } + + private void CloseFullscreen() + { + _timer.Stop(); + + try + { + ExitPosition = fsPlayer.Position; + // Capture the actual volume (not muted value) + ExitVolume = _volumeBeforeMute; + ExitMuted = _isMuted; + fsPlayer.Stop(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error capturing fullscreen exit state."); + ExitPosition = TimeSpan.Zero; + WasPlaying = false; + ExitVolume = 0.5; + ExitMuted = false; + } + + Close(); + } + } +} diff --git a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml index 786ba9ec6e..a7a29f3c4a 100644 --- a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml +++ b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml @@ -1,4 +1,4 @@ - - +