diff --git a/.gitignore b/.gitignore index b89e12b910..f2e478e512 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ packages/ # Ignore everything else in release folders **/bin/[Rr]elease*/ /.stfolder/ + +# Agent and temporary build files +.agent/ +source/build_output.txt diff --git a/documentation/PRD-FullScreenVideos.md b/documentation/PRD-FullScreenVideos.md new file mode 100644 index 0000000000..946ddda9cf --- /dev/null +++ b/documentation/PRD-FullScreenVideos.md @@ -0,0 +1,75 @@ +# Product Requirements Document (PRD) + +**Project:** Add Fullscreen Video Capability to Extra Metadata Loader + +**Target Repository:** `PlayniteExtensionsCollection` (by darklinkpower) + +**Extension Component:** Extra Metadata Loader (EML) + +**Framework:** C# / WPF (.NET) + +## 1. Overview and Objective + +The Extra Metadata Loader currently plays local video trailers within an embedded WPF `MediaElement` constrained by the parent grid of the Playnite UI (e.g., the Harmony theme). The objective is to implement a seamless fullscreen viewing experience for these local `.mp4` and `.webm` files. + +The user must be able to trigger fullscreen mode via two distinct methods: a dedicated UI button and a double-click action on the video surface. + +## 2. Functional Requirements + +### 2.1. Fullscreen Triggers (Entry) + +- **Method A (Double-Click):** Implement a `MouseDoubleClick` event listener on the primary video rendering surface (the embedded `MediaElement` or its immediate parent container). +- **Method B (UI Button):** Introduce a new toggle button into the existing video control bar. + - **Placement:** Based on the current control layout, the new button should be placed on the far-right side of the control bar, immediately following the volume slider. + - **Iconography:** Use a standard "Maximize/Fullscreen" vector path or icon that matches the existing flat, monochromatic aesthetic of the EML control bar. + +### 2.2. The Fullscreen Window Architecture + +WPF restricts embedded elements from breaking out of their parent containers to cover the entire screen. Therefore, the fullscreen state must be handled by spawning a new `Window`. + +- **Window Properties:** * `WindowStyle = WindowStyle.None` + - `WindowState = WindowState.Maximized` + - `Topmost = true` + - `Background = Brushes.Black` +- **Video Handoff:** + - Upon triggering fullscreen, capture the current `Source` (URI), `Position` (TimeSpan), and `IsPlaying` (Boolean) state of the embedded `MediaElement`. + - Pause the embedded `MediaElement`. + - Instantiate a new `MediaElement` in the fullscreen window. + - Pass the captured `Source` and `Position` to the new `MediaElement` and resume playback if it was playing. + +### 2.3. Fullscreen Controls & Exit Triggers + +- **Exit Method A (Keyboard):** Pressing the `Escape` key while the fullscreen window is in focus must trigger the exit routine. +- **Exit Method B (Double-Click):** Double-clicking the video surface within the fullscreen window must trigger the exit routine. +- **Exit Method C (UI Button):** The fullscreen window must contain a minimal control bar (or overlay button) to manually exit fullscreen. +- **State Return Handoff:** * Capture the final `Position` and `IsPlaying` state from the fullscreen window. + - Close and destroy the fullscreen window. + - Update the original embedded `MediaElement` to the new `Position` and resume playback if it was active. + +## 3. Implementation Plan for the AI Assistant + +### Phase 1: XAML Modifications (UI) + +1. Locate the primary XAML UserControl file responsible for the video player in the Extra Metadata Loader project (likely named something like `VideoPlayerControl.xaml` or similar within the EML views folder). +2. Add a `MouseDoubleClick` event handler to the `MediaElement` or its wrapper `Grid`. +3. Locate the `StackPanel` or `Grid` holding the control bar (Play, Autoplay, Mute, Volume). +4. Inject a new `Button` at the end of this layout stack for the Fullscreen action, utilizing a standard SVG/Path geometry for the icon. + +### Phase 2: C# Code-Behind Logic + +1. Open the corresponding `.xaml.cs` code-behind file. +2. Implement the event handlers for the new button click and the double-click events. +3. Create a method to handle the state capture and Window instantiation (e.g., `EnterFullscreen()`). + +### Phase 3: The Fullscreen Window Class + +1. Create a new WPF Window class (e.g., `FullscreenVideoWindow.xaml`). +2. Define the minimal UI for this window (a black background grid, a `MediaElement` bound to the window's dimensions, and a hidden/auto-hiding exit button). +3. Implement the constructor to accept the video URI and starting timestamp. +4. Implement the exit logic (`Escape` key listener, double-click listener) to fire an event back to the parent control containing the exit timestamp, then call `this.Close()`. + +## 4. Edge Cases to Handle + +- **Volume Sync:** Ensure the volume level of the fullscreen player matches the volume slider state of the embedded player. +- **Missing File/Stream Drop:** Handle exceptions gracefully if the `MediaElement` fails to initialize the source URI during the handoff. +- **Focus Loss:** Ensure the fullscreen window remains topmost unless explicitly minimized or closed by the user. \ No newline at end of file diff --git a/documentation/Readme-FullScreenVideo.md b/documentation/Readme-FullScreenVideo.md new file mode 100644 index 0000000000..17cabfaf4c --- /dev/null +++ b/documentation/Readme-FullScreenVideo.md @@ -0,0 +1,70 @@ +# Fullscreen Video Capability - Extra Metadata Loader + +> [!NOTE] +> **Maintainer Notice: Dual PR Submission** +> This feature has been implemented and submitted via two separate Pull Requests to give you maximum flexibility: +> - **PR #731 (This one)**: Built against the `master` branch (your WIP rewrite). +> - **PR #732**: Ported and surgically built against the stable `emlCrashFix2025` branch, ensuring 0 regressions for the current live plugin. +> Both PRs contain the exact same UI polish, including a recent patch to prevent interactive buttons from stealing Spacebar keyboard focus. + +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 Bug Fixes Included + +This branch includes robust fixes to the WPF `MediaElement` implementation: +- **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 Repair**: Resolved 18 pre-existing namespace collisions and missing code references in `VideosDownloader.cs`, `SteamMetadataProvider.cs`, and `ExtraMetadataLoader.cs` related to an incomplete service-layer refactoring. Resolves `MouseDoubleClick` missing on `Grid` elements. + +## πŸ§ͺ 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 @@ -ο»Ώ - +