diff --git a/.gitignore b/.gitignore index 3629fd082..1a3370664 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 000000000..4f255e7cf --- /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 000000000..82264f599 --- /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 000000000..7fd49c68d --- /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 786ba9ec6..a7a29f3c4 100644 --- a/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml +++ b/source/Generic/ExtraMetadataLoader/Controls/VideoPlayerControl.xaml @@ -1,4 +1,4 @@ -ο»Ώ - +