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/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/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/Common/SteamCommon/Web.cs b/source/Common/SteamCommon/Web.cs index 022221e4d1..1724ba90b3 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}"); @@ -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/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/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() 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 @@ - - +