From 0909e746bfa8e1da765bad4022d612edcbb1aec7 Mon Sep 17 00:00:00 2001 From: FaulMit <48646918+FaulMit@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:33:02 +0200 Subject: [PATCH] Release Captail 0.1.7 --- CHANGELOG.md | 21 + README.md | 8 +- THIRD_PARTY_NOTICES.md | 10 + docs/RELEASING.md | 8 +- docs/readme-store.svg | 10 + packaging/msix/README.md | 15 +- site/index.html | 16 +- src/Captail/App.xaml.cs | 278 ++++++++- src/Captail/Captail.csproj | 23 +- src/Captail/ClipEditorWindow.xaml | 131 +++- src/Captail/ClipEditorWindow.xaml.cs | 755 ++++++++++++++++++---- src/Captail/FfmpegAdapter.cs | 92 ++- src/Captail/FfplayHost.cs | 494 --------------- src/Captail/Interop/CaptureInterop.cs | 27 + src/Captail/Languages/Strings.en.xaml | 8 +- src/Captail/Languages/Strings.ru.xaml | 8 +- src/Captail/MpvHost.cs | 824 +++++++++++++++++++++++++ src/Captail/ObsPluginDataCache.cs | 211 +++++++ src/Captail/ObsReplayEngine.cs | 117 +++- src/Captail/ReplayLibrary.cs | 13 +- src/Captail/SettingsWindow.xaml | 17 +- src/Captail/SettingsWindow.xaml.cs | 26 +- src/Captail/Themes/Theme.xaml | 104 ++++ src/Captail/packages.lock.json | 6 + tools/AcquireFfmpegRuntime.ps1 | 5 - tools/BuildRelease.ps1 | 4 +- tools/BuildStorePackage.ps1 | 3 +- tools/GenerateStoreAssets.ps1 | 486 +++++++++++++++ tools/TestGameCaptureHookIsolation.ps1 | 98 +++ 29 files changed, 3120 insertions(+), 698 deletions(-) create mode 100644 docs/readme-store.svg delete mode 100644 src/Captail/FfplayHost.cs create mode 100644 src/Captail/MpvHost.cs create mode 100644 src/Captail/ObsPluginDataCache.cs create mode 100644 tools/GenerateStoreAssets.ps1 create mode 100644 tools/TestGameCaptureHookIsolation.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 214513d..ab026c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable user-facing changes are documented here. ## [Unreleased] +## [0.1.7] - 2026-08-04 + +### Added + +- **Fast embedded replay preview:** The clip editor now uses a hardware-accelerated built-in player with responsive seeking, simultaneous playback of selected audio tracks, fullscreen viewing, keyboard controls, and a fullscreen progress bar. +- **Clear loading states:** The replay library and clip preview now distinguish loading, empty, failed, and ready states instead of appearing blank while work is in progress. +- **Microsoft Store availability:** Captail can now be installed from Microsoft Store with Store-managed updates. + +### Improved + +- **Safer clip editing:** Saving and overwriting show a focused progress overlay, release the preview file before replacement, retry short Windows file-lock races, and keep temporary working files out of the replay library. +- **Automatic capture switching:** Desktop mode uses Game Capture only while the hooked game is producing video and remains the foreground application. Alt-tabbing returns recording to the desktop instead of leaving a stale game frame active. + +### Fixed + +- Fixed black or incorrectly cropped video in the clip editor while keeping fast hardware-accelerated playback. +- Fixed native video covering overwrite confirmation and saving overlays. +- Fixed non-game applications such as Telegram being selected as the active automatic capture source. +- Fixed closing Captail leaving its installation or Portable folder locked by a running game. Game Capture hooks now load from a validated per-user cache instead of the application directory. +- Fixed misaligned loading indicators and saving text in the replay library and clip editor. + ## [0.1.6] - 2026-08-03 ### Added diff --git a/README.md b/README.md index c2eab0e..2f7b396 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@

Download latest Captail release for Windows + Get Captail from Microsoft Store

@@ -64,15 +65,16 @@ Captail is not a streaming application, scene compositor, DRM bypass, or cloud c ## How do I install it? -Open [GitHub Releases](https://github.com/FaulMit/captail/releases) and choose one package: +Install Captail from [Microsoft Store](https://apps.microsoft.com/detail/9PKVNVLKPTPS), or open [GitHub Releases](https://github.com/FaulMit/captail/releases) and choose a package: | Package | Choose it when | Installation | | --- | --- | --- | +| **Microsoft Store** | You want Microsoft-managed installation and automatic updates | Open the [Store listing](https://apps.microsoft.com/detail/9PKVNVLKPTPS) and select **Install**. | | `Captail-x.y.z-Setup-win-x64.exe` | You want the normal Windows experience | Run Setup. It installs Captail for your Windows account and adds an uninstaller to Windows Settings. | | `Captail-x.y.z-Portable-win-x64.zip` | You want a movable, self-contained folder | Extract the entire ZIP, then run `Captail.exe` inside it. Do not run it from the archive. | | `SHA256SUMS.txt` | You want to verify the download | Compare the package SHA-256 with the published value before running it. | -Both packages include .NET, libobs, and FFmpeg. You do not need to install OBS Studio or extra runtimes. +Every Captail package includes .NET, libobs, FFmpeg, and the embedded preview player. You do not need to install OBS Studio or extra runtimes. > [!NOTE] > Release binaries are not Authenticode-signed yet. Windows SmartScreen may show “Unknown publisher.” Verify `SHA256SUMS.txt` and GitHub build provenance if you want to confirm the download. @@ -189,7 +191,7 @@ Enable **Organize games into folders** in Storage. Replays saved while a game is No. Captail has no account, cloud upload, analytics, or telemetry. Replays, thumbnails, settings, and logs stay on your PC. -Captail contacts the GitHub Releases API to check whether a newer version exists. It downloads an update package only after you click the update control. +GitHub builds contact the GitHub Releases API to check whether a newer version exists. They download an update package only after you click the update control. The Microsoft Store build uses Store-managed updates instead. ## How is Captail different from ShadowPlay and OBS Replay Buffer? diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0c088a8..7e70af8 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -20,9 +20,19 @@ Captail dynamically uses and may redistribute selected components from OBS Studi `tools/AcquireFfmpegRuntime.ps1` downloads a pinned, SHA-256-verified build. Captail uses it for clip metadata, thumbnails, and non-destructive trimming. +## mpv / libmpv + +- Project: https://mpv.io/ +- Source release: https://github.com/mpv-player/mpv/tree/v0.41.0 +- Native Windows package: `Endpne.LibMPV.Windows` 0.41.0 +- License: GNU Lesser General Public License 2.1 or later + +Captail dynamically loads the replaceable `libmpv-2.dll` for embedded editor playback, hardware decoding, seeking, and local mixing of selected audio tracks. Captail does not bundle or launch the standalone `mpv.exe` player. + ## NuGet dependencies - NAudio — MIT License: https://github.com/naudio/NAudio +- Endpne.LibMPV.Windows — LGPL-2.1-or-later: https://www.nuget.org/packages/Endpne.LibMPV.Windows/0.41.0 - H.NotifyIcon — MIT License: https://github.com/HavenDV/H.NotifyIcon - System.Drawing.Common — MIT License: https://github.com/dotnet/runtime diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1361a4e..102f630 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -20,8 +20,8 @@ Before running the workflow: ```powershell .\tools\New-ReleaseNotes.ps1 ` - -Version 0.1.6 ` - -PreviousTag v0.1.5 ` + -Version 0.1.7 ` + -PreviousTag v0.1.6 ` -OutputPath "$env:TEMP\captail-release-notes.md" ``` @@ -85,9 +85,9 @@ GitHub Release because Store installs, signs, and updates the package. Build an upload-ready package locally: ```powershell -.\tools\BuildStorePackage.ps1 -Version 0.1.5 +.\tools\BuildStorePackage.ps1 -Version 0.1.7 ``` -Upload `artifacts\store\0.1.5\Captail-0.1.5.0-x64.msixupload` in Partner +Upload `artifacts\store\0.1.7\Captail-0.1.7.0-x64.msixupload` in Partner Center. Full identity, validation, update-channel, and submission instructions are in [`packaging/msix/README.md`](../packaging/msix/README.md). diff --git a/docs/readme-store.svg b/docs/readme-store.svg new file mode 100644 index 0000000..34da25d --- /dev/null +++ b/docs/readme-store.svg @@ -0,0 +1,10 @@ + + Get Captail from Microsoft Store + Graphite Microsoft Store button with automatic update support. + + + + Get it from Microsoft Store + Automatic Store updates + + diff --git a/packaging/msix/README.md b/packaging/msix/README.md index d7b80b1..34abf35 100644 --- a/packaging/msix/README.md +++ b/packaging/msix/README.md @@ -11,7 +11,10 @@ Windows reports package identity. - Publisher: `CN=1BD9448E-C83F-401D-B530-ED5258C6319A` - Publisher display name: `faulmit` - Package family name: `faulmit.Captail_ryepxmzt2jqew` +- Package SID: `S-1-15-2-2570374564-3604720510-221884595-2273346317-2344629589-1903721979-2982860682` - Store product ID: `9PKVNVLKPTPS` +- Store protocol link: `ms-windows-store://pdp/?productid=9PKVNVLKPTPS` +- Store web listing: Do not change package name or publisher. Partner Center rejects packages whose manifest identity does not match the reserved product. @@ -21,15 +24,15 @@ manifest identity does not match the reserved product. From repository root: ```powershell -.\tools\BuildStorePackage.ps1 -Version 0.1.5 +.\tools\BuildStorePackage.ps1 -Version 0.1.7 ``` Output: ```text -artifacts\store\0.1.5\Captail-0.1.5.0-x64.msix -artifacts\store\0.1.5\Captail-0.1.5.0-x64.msixupload -artifacts\store\0.1.5\SHA256SUMS.txt +artifacts\store\0.1.7\Captail-0.1.7.0-x64.msix +artifacts\store\0.1.7\Captail-0.1.7.0-x64.msixupload +artifacts\store\0.1.7\SHA256SUMS.txt ``` Upload `.msixupload` on Partner Center's **Packages** page. Do not publish this @@ -43,8 +46,8 @@ certification. ## Versioning -MSIX requires four numeric components. Captail `0.1.5` becomes package version -`0.1.5.0`. Every Store submission must use a strictly higher package version. +MSIX requires four numeric components. Captail `0.1.7` becomes package version +`0.1.7.0`. Every Store submission must use a strictly higher package version. Never reuse a version already submitted to Partner Center. ## Store-specific behavior diff --git a/site/index.html b/site/index.html index 0b89395..004d1f7 100644 --- a/site/index.html +++ b/site/index.html @@ -30,9 +30,9 @@ "name": "Captail", "applicationCategory": "UtilitiesApplication", "operatingSystem": "Windows 10 version 2004 or newer, Windows 11", - "softwareVersion": "0.1.6", + "softwareVersion": "0.1.7", "description": "Free, open-source Instant Replay utility for Windows with a rolling buffer and recovery watchdog.", - "downloadUrl": "https://github.com/FaulMit/captail/releases/download/v0.1.6/Captail-0.1.6-Setup-win-x64.exe", + "downloadUrl": "https://github.com/FaulMit/captail/releases/download/v0.1.7/Captail-0.1.7-Setup-win-x64.exe", "codeRepository": "https://github.com/FaulMit/captail", "license": "https://www.gnu.org/licenses/old-licenses/gpl-2.0.html", "offers": { @@ -71,7 +71,7 @@

-

Windows replay utility · v0.1.6 preview

+

Windows replay utility · v0.1.7 preview

Instant Replay that tries to stay on.

Captail keeps recent gameplay ready, watches its capture pipeline, and attempts recovery when recording stops or stalls.

@@ -90,7 +90,7 @@

Instant Replay that tries to stay on.

Captail main window showing Instant Replay status, audio sources, format, disk space, and recent replays @@ -385,10 +385,10 @@

Keep the last moment within reach.

Windows x64 · Early preview

Choose your build.

-

Latest GitHub preview · v0.1.6

+

Latest GitHub preview · v0.1.7

Unsigned preview. Windows may show “Unknown publisher.”

-
Release notes, checksums, and provenance + Release notes, checksums, and provenance
diff --git a/src/Captail/App.xaml.cs b/src/Captail/App.xaml.cs index 45e3ff1..5ac61c0 100644 --- a/src/Captail/App.xaml.cs +++ b/src/Captail/App.xaml.cs @@ -31,10 +31,12 @@ public partial class App : Application private string _activationPipeName = ""; private string? _pendingUiError; private DispatcherTimer? _healthTimer; + private DispatcherTimer? _captureStateTimer; private DateTime _pipelineStartedUtc; private DateTime _nextRecoveryUtc; private int _recoveryFailures; private int _recoveryInProgress; + private int _captureStateRefreshInProgress; private OverlayNotificationWindow? _overlayNotification; private readonly UpdateService _updateService = new(); private DispatcherTimer? _updateShutdownTimer; @@ -77,6 +79,12 @@ protected override async void OnStartup(StartupEventArgs e) bool replaySegmentsTest = e.Args.Contains( "--qa-replay-segments", StringComparer.OrdinalIgnoreCase); + bool fileRetryTest = e.Args.Contains( + "--qa-file-retry", + StringComparer.OrdinalIgnoreCase); + bool automaticCapturePolicyTest = e.Args.Contains( + "--qa-auto-capture-policy", + StringComparer.OrdinalIgnoreCase); bool updateCheckTest = e.Args.Contains( "--qa-update-check", StringComparer.OrdinalIgnoreCase); @@ -99,6 +107,13 @@ protected override async void OnStartup(StartupEventArgs e) ?["--qa-preview-geometry=".Length..]; bool previewGeometryTest = !string.IsNullOrWhiteSpace(previewGeometryTestPath); + string? trimOverwriteTestPath = e.Args + .FirstOrDefault(argument => argument.StartsWith( + "--qa-trim-overwrite=", + StringComparison.OrdinalIgnoreCase)) + ?["--qa-trim-overwrite=".Length..]; + bool trimOverwriteTest = + !string.IsNullOrWhiteSpace(trimOverwriteTestPath); _qaUpdateAvailable = e.Args.Contains( "--qa-update-available", StringComparer.OrdinalIgnoreCase); @@ -108,10 +123,13 @@ protected override async void OnStartup(StartupEventArgs e) const bool capabilityModelTest = false; const bool gameCaptureTest = false; const bool replaySegmentsTest = false; + const bool fileRetryTest = false; + const bool automaticCapturePolicyTest = false; const bool updateCheckTest = false; const bool clipEditorTest = false; const bool audioMixTest = false; const bool previewGeometryTest = false; + const bool trimOverwriteTest = false; #endif bool backgroundLaunch = e.Args.Contains( "--background", @@ -125,7 +143,9 @@ protected override async void OnStartup(StartupEventArgs e) shutdownExisting, _uiOnly || faultTest || codecTest || capabilityModelTest || gameCaptureTest || replaySegmentsTest || updateCheckTest || - clipEditorTest || audioMixTest || previewGeometryTest)) + clipEditorTest || audioMixTest || previewGeometryTest || + fileRetryTest || trimOverwriteTest || + automaticCapturePolicyTest)) { Shutdown(); return; @@ -179,6 +199,16 @@ protected override async void OnStartup(StartupEventArgs e) RunReplaySegmentsTest(); return; } + if (fileRetryTest) + { + await RunFileRetryTestAsync(); + return; + } + if (automaticCapturePolicyTest) + { + RunAutomaticCapturePolicyTest(); + return; + } if (updateCheckTest) { await _updateService.CheckAsync( @@ -202,6 +232,11 @@ await _updateService.CheckAsync( await RunPreviewGeometryTestAsync(previewGeometryTestPath!); return; } + if (trimOverwriteTest) + { + await RunTrimOverwriteTestAsync(trimOverwriteTestPath!); + return; + } #endif if (_uiOnly) { @@ -235,6 +270,7 @@ await _updateService.CheckAsync( CreateTrayIcon(); BindHotkeyAtStartup(); StartHealthMonitor(); + StartCaptureStateMonitor(); StartActivationServer(); if (!backgroundLaunch) OpenSettings(); @@ -263,6 +299,188 @@ await TryStartPipelineAsync(showError: true)) } #if DEBUG + private void RunAutomaticCapturePolicyTest() + { + string[] rejected = + [ + "Telegram.exe", @"C:\Apps\Telegram.exe", "Discord.exe", "chrome.exe", "msedge.exe", + "firefox.exe", "explorer.exe", "dwm.exe", "Spotify.exe", + "vlc.exe", "mpv.exe", "obs64.exe", "Captail.exe", + "ApplicationFrameHost.exe", "ShellExperienceHost.exe", "SearchHost.exe", + ]; + string[] accepted = + [ + "cs2.exe", @"D:\SteamLibrary\steamapps\common\Counter-Strike Global Offensive\game\bin\win64\cs2.exe", + "GTA5.exe", "hl2.exe", + "ExampleGame-Win64-Shipping.exe", "Minecraft.Windows.exe", + ]; + string[] falsePositives = rejected + .Where(ObsReplayEngine.IsAutomaticCaptureCandidate) + .ToArray(); + string[] falseNegatives = accepted + .Where(executable => + !ObsReplayEngine.IsAutomaticCaptureCandidate(executable)) + .ToArray(); + bool altTabRejected = !ObsReplayEngine.ShouldUseAutomaticGameCapture( + "cs2.exe", + "explorer.exe", + hasVideo: true); + bool focusedGameAccepted = ObsReplayEngine.ShouldUseAutomaticGameCapture( + "cs2.exe", + "CS2.exe", + hasVideo: true); + bool missingVideoRejected = !ObsReplayEngine.ShouldUseAutomaticGameCapture( + "cs2.exe", + "cs2.exe", + hasVideo: false); + bool passed = falsePositives.Length == 0 && + falseNegatives.Length == 0 && + !ObsReplayEngine.IsAutomaticCaptureCandidate("") && + altTabRejected && focusedGameAccepted && + missingVideoRejected; + Log.Write( + $"AUTO_CAPTURE_POLICY_TEST {(passed ? "PASS" : "FAIL")}: " + + $"falsePositives={string.Join(',', falsePositives)}, " + + $"falseNegatives={string.Join(',', falseNegatives)}, " + + $"altTabRejected={altTabRejected}, " + + $"focusedGameAccepted={focusedGameAccepted}, " + + $"missingVideoRejected={missingVideoRejected}"); + Shutdown(passed ? 0 : 22); + } + + private async Task RunTrimOverwriteTestAsync(string path) + { + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + throw new FileNotFoundException("QA replay does not exist.", fullPath); + + string directory = Path.Combine( + Path.GetTempPath(), + $"captail_trim_overwrite_{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + string workingPath = Path.Combine( + directory, + "overwrite-source" + Path.GetExtension(fullPath)); + try + { + File.Copy(fullPath, workingPath); + var ffmpeg = new FfmpegAdapter(); + TimeSpan originalDuration = await ffmpeg.ReadDurationAsync(workingPath); + IReadOnlyList audioTracks = + await ffmpeg.ReadAudioTracksAsync(workingPath); + var file = new FileInfo(workingPath); + var clip = new ReplayClip( + workingPath, + file.Name, + null, + file.LastWriteTime, + file.Length, + originalDuration, + null); + TimeSpan start = TimeSpan.FromMilliseconds(250); + TimeSpan end = originalDuration - TimeSpan.FromMilliseconds(500); + if (end <= start) + throw new InvalidOperationException("QA replay must be longer than one second."); + + var library = new ReplayLibrary(ffmpeg); + await library.TrimOverwriteAsync( + directory, + clip, + start, + end, + audioTracks.Select(track => track.StreamIndex).ToArray()); + TimeSpan trimmedDuration = await ffmpeg.ReadDurationAsync(workingPath); + string[] internalFiles = Directory.EnumerateFiles(directory) + .Where(ReplayLibrary.IsInternalWorkingFile) + .ToArray(); + bool passed = File.Exists(workingPath) && + new FileInfo(workingPath).Length > 0 && + trimmedDuration < originalDuration && + internalFiles.Length == 0; + Log.Write( + $"TRIM_OVERWRITE_TEST {(passed ? "PASS" : "FAIL")}: " + + $"duration={originalDuration.TotalSeconds:0.000}->" + + $"{trimmedDuration.TotalSeconds:0.000}, " + + $"audioTracks={audioTracks.Count}, leftovers={internalFiles.Length}"); + Shutdown(passed ? 0 : 21); + } + catch (Exception exception) + { + Log.Write($"TRIM_OVERWRITE_TEST FAIL: {exception}"); + Shutdown(21); + } + finally + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (Exception exception) + { + Log.Write($"Trim overwrite QA cleanup failed: {exception.Message}"); + } + } + } + + private async Task RunFileRetryTestAsync() + { + string directory = Path.Combine( + Path.GetTempPath(), + $"captail_file_retry_{Guid.NewGuid():N}"); + string source = Path.Combine(directory, "clip.tmp"); + string destination = Path.Combine(directory, "clip.mkv"); + Directory.CreateDirectory(directory); + try + { + await File.WriteAllTextAsync(source, "captail"); + using var locked = new ManualResetEventSlim(); + Task locker = Task.Run(() => + { + using FileStream stream = File.Open( + source, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None); + locked.Set(); + Thread.Sleep(650); + }); + locked.Wait(); + + var watch = Stopwatch.StartNew(); + await FfmpegAdapter.MoveFileWithRetryAsync(source, destination); + watch.Stop(); + await locker; + + const string legacyWorkingName = + ".Replay.test.replacement.mkv.deadbeef.tmp.mkv"; + bool filtered = ReplayLibrary.IsInternalWorkingFile(legacyWorkingName); + bool passed = File.Exists(destination) && + watch.ElapsedMilliseconds >= 500 && + filtered; + Log.Write( + $"FILE_RETRY_TEST {(passed ? "PASS" : "FAIL")}: " + + $"moved={File.Exists(destination)}, " + + $"elapsed={watch.ElapsedMilliseconds}ms, filtered={filtered}"); + Shutdown(passed ? 0 : 20); + } + catch (Exception exception) + { + Log.Write($"FILE_RETRY_TEST FAIL: {exception}"); + Shutdown(20); + } + finally + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (Exception exception) + { + Log.Write($"File retry QA cleanup failed: {exception.Message}"); + } + } + } + private async Task RunAudioMixTestAsync(string path) { string fullPath = Path.GetFullPath(path); @@ -1098,6 +1316,62 @@ private void StartHealthMonitor() _healthTimer.Start(); } + private void StartCaptureStateMonitor() + { + _captureStateTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(500), + }; + _captureStateTimer.Tick += async (_, _) => + await RefreshAutomaticCaptureStateSafeAsync(); + _captureStateTimer.Start(); + } + + private async Task RefreshAutomaticCaptureStateSafeAsync() + { + if (_uiOnly || !IsReplayRunning || + Volatile.Read(ref _exiting) != 0 || + Interlocked.Exchange(ref _captureStateRefreshInProgress, 1) != 0) + { + return; + } + + try + { + if (!await _pipelineGate.WaitAsync(0)) + return; + try + { + ObsReplayEngine? engine = _obs; + if (engine is null || !engine.IsAutomaticCapture) + return; + (bool changed, string description) = + await RunOnObsThreadAsync(() => + { + bool sourceChanged = engine.RefreshCaptureState(); + return (sourceChanged, engine.Description); + }); + if (changed && ReferenceEquals(engine, _obs)) + { + _captureDescription = description; + UpdateUiState(); + } + } + finally + { + _pipelineGate.Release(); + } + } + catch (Exception exception) + { + Log.Write($"Capture source monitor failed: {exception.Message}"); + } + finally + { + Interlocked.Exchange(ref _captureStateRefreshInProgress, 0); + } + } + private async Task MonitorPipelineSafeAsync() { try @@ -2041,6 +2315,7 @@ private async Task RequestShutdownAsync() return; _healthTimer?.Stop(); + _captureStateTimer?.Stop(); await _pipelineGate.WaitAsync(); try { @@ -2063,6 +2338,7 @@ protected override void OnExit(ExitEventArgs e) Interlocked.Exchange(ref _exiting, 1) != 0 && _obs is null; Localization.Changed -= OnLanguageChanged; _healthTimer?.Stop(); + _captureStateTimer?.Stop(); _updateShutdownTimer?.Stop(); _activationServerCts?.Cancel(); _activationServerCts?.Dispose(); diff --git a/src/Captail/Captail.csproj b/src/Captail/Captail.csproj index 4dac14b..972cbbd 100644 --- a/src/Captail/Captail.csproj +++ b/src/Captail/Captail.csproj @@ -14,13 +14,15 @@ Captail Captail Assets\Captail.ico - 0.1.6 + 0.1.7 true $(DefineConstants);MICROSOFT_STORE + @@ -49,8 +51,19 @@ true $(MSBuildProjectDirectory)\..\..\native\ObsBridge $(ObsBridgeSource)\build + false + false + + + true + PreserveNewest + PreserveNewest + + + @@ -67,22 +80,26 @@ AfterTargets="Build" DependsOnTargets="AcquireFfmpegRuntime"> - <_FfmpegBuildFile Include="$(FfmpegRuntimeRoot)\**\*" /> + <_FfmpegBuildFile Include="$(FfmpegRuntimeRoot)\**\*" + Exclude="$(FfmpegRuntimeRoot)\ffplay.exe" /> + - <_FfmpegPublishFile Include="$(FfmpegRuntimeRoot)\**\*" /> + <_FfmpegPublishFile Include="$(FfmpegRuntimeRoot)\**\*" + Exclude="$(FfmpegRuntimeRoot)\ffplay.exe" /> + diff --git a/src/Captail/ClipEditorWindow.xaml b/src/Captail/ClipEditorWindow.xaml index 6578585..fa65780 100644 --- a/src/Captail/ClipEditorWindow.xaml +++ b/src/Captail/ClipEditorWindow.xaml @@ -68,16 +68,16 @@ - - - + + - @@ -101,32 +101,109 @@ - + - - - - + + + + - - + - + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Captail/ClipEditorWindow.xaml.cs b/src/Captail/ClipEditorWindow.xaml.cs index c27b4f3..ad5d2a7 100644 --- a/src/Captail/ClipEditorWindow.xaml.cs +++ b/src/Captail/ClipEditorWindow.xaml.cs @@ -1,6 +1,5 @@ using System.Collections.ObjectModel; using System.ComponentModel; -using System.Diagnostics; using System.IO; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -10,6 +9,7 @@ using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; +using System.Windows.Media.Animation; using System.Windows.Media.Imaging; using System.Windows.Threading; @@ -19,13 +19,15 @@ public partial class ClipEditorWindow : Window { private const double MinimumSelectionSeconds = 0.25; private const int TimelineFrameCount = 12; + private static readonly TimeSpan FullscreenControlsTimeout = TimeSpan.FromSeconds(2.4); + private const double FullscreenControlsHeight = 58; private readonly ReplayLibrary _library; private readonly string _rootDirectory; private readonly ReplayClip _clip; private readonly Action _onSaved; private readonly CancellationTokenSource _lifetimeCts = new(); private readonly DispatcherTimer _playbackTimer; - private readonly Stopwatch _playbackClock = new(); + private readonly DispatcherTimer _fullscreenUiTimer; private readonly List _timelineImages = []; private double _selectionStart; private double _selectionEnd; @@ -33,7 +35,18 @@ public partial class ClipEditorWindow : Window private bool _playing; private bool _playerLoading; private bool _resumeAfterScrub; - private int _stillRequest; + private bool _fullscreenProgressScrubbing; + private bool _updatingFullscreenProgress; + private bool _resumeAfterOverwriteConfirmation; + private bool _saveInProgress; + private Visibility _playerVisibilityBeforeOverwrite = Visibility.Collapsed; + private Visibility _imageVisibilityBeforeOverwrite = Visibility.Visible; + private bool _isFullscreen; + private bool _restoreTopmost; + private Rect _restoreBounds; + private WindowState _restoreWindowState; + private NativePoint _lastCursorPosition; + private DateTime _lastPointerActivityUtc; private VideoStreamInfo? _videoInfo; public ObservableCollection AudioTracks { get; } = []; @@ -59,27 +72,28 @@ public ClipEditorWindow( _playbackTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(50) }; _playbackTimer.Tick += async (_, _) => await UpdatePlaybackAsync(); + _fullscreenUiTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) }; + _fullscreenUiTimer.Tick += (_, _) => UpdateFullscreenControls(); Loaded += async (_, _) => await LoadEditorAsync(); SourceInitialized += (_, _) => ApplyNativeCornerPreference(); Closed += (_, _) => { _playbackTimer.Stop(); - _playbackClock.Stop(); + _fullscreenUiTimer.Stop(); + Topmost = _restoreTopmost; _lifetimeCts.Cancel(); - PreviewPlayer.Stop(); + PreviewPlayer.Shutdown(); _lifetimeCts.Dispose(); }; } private async Task LoadEditorAsync() { - await Task.WhenAll( - LoadTimelineThumbnailsAsync(), - LoadAudioTracksAsync(), - LoadVideoInfoAsync(), - UpdateStillFrameAsync(_selectionStart)); - if (!_lifetimeCts.IsCancellationRequested) - PlayButton.IsEnabled = true; + Task timelineTask = LoadTimelineThumbnailsAsync(); + Task videoInfoTask = LoadVideoInfoAsync(); + await LoadAudioTracksAsync(); + await InitializePreviewAsync(); + await Task.WhenAll(timelineTask, videoInfoTask); } private async Task LoadVideoInfoAsync() @@ -108,15 +122,118 @@ private async Task LoadVideoInfoAsync() { await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Loaded); await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render); - await Task.Delay(200, _lifetimeCts.Token); + DateTime readyDeadline = DateTime.UtcNow.AddSeconds(10); + while ((_playerLoading || !PreviewPlayer.IsReady) && + DateTime.UtcNow < readyDeadline) + { + await Task.Delay(50, _lifetimeCts.Token); + } + if (!PreviewPlayer.IsReady) + return (false, "preview player did not become ready"); + + RequestOverwrite_Click(this, new RoutedEventArgs()); + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render); + bool overwriteOpened = + OverwriteConfirmOverlay.Visibility == Visibility.Visible; + bool playerSuppressed = PreviewPlayer.Visibility != Visibility.Visible; + CancelOverwrite(); + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render); + bool playerRestored = PreviewPlayer.Visibility == Visibility.Visible; + bool overwriteOverlayPassed = + overwriteOpened && playerSuppressed && playerRestored; + + PreviewPlayer.Visibility = Visibility.Collapsed; + SavingStatusText.Text = Localization.Text("L.Library.Trimming"); + ShowSavingOverlay(); + await Task.Delay(220, _lifetimeCts.Token); + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render); + string savingScreenshot = Path.Combine( + Path.GetTempPath(), + "Captail", + "saving-overlay-qa.png"); + CaptureVisualToPng(savingScreenshot); + bool savingOverlayPassed = + SavingOverlay.Visibility == Visibility.Visible && + PreviewPlayer.Visibility != Visibility.Visible && + TextOptions.GetTextRenderingMode(SavingOverlay) == + TextRenderingMode.Grayscale; + HideSavingOverlay(); + PreviewPlayer.Visibility = Visibility.Visible; + await StartPlaybackAsync(_selectionStart); - await Task.Delay(700, _lifetimeCts.Token); - string details = "preview window is not ready"; - bool passed = PreviewPlayer.IsReady && - PreviewPlayer.TryValidateGeometry(out details); - StopNativePlayback(); + double playbackStart = PreviewPlayer.PositionSeconds; + double playbackAfter = playbackStart; + DateTime playbackDeadline = DateTime.UtcNow.AddSeconds(3); + while (playbackAfter < playbackStart + 0.2 && + DateTime.UtcNow < playbackDeadline) + { + await Task.Delay(100, _lifetimeCts.Token); + playbackAfter = PreviewPlayer.PositionSeconds; + } + bool clockAdvanced = playbackAfter >= playbackStart + 0.2; + + PreviewPlayer.Pause(); + _playing = false; + _playbackTimer.Stop(); + double seekTarget = Math.Clamp( + _clip.Duration.TotalSeconds * 0.5, + _selectionStart, + _selectionEnd); + PreviewPlayer.Seek(seekTarget, exact: true); + await Task.Delay(350, _lifetimeCts.Token); + double seekPosition = PreviewPlayer.PositionSeconds; + bool seekPassed = Math.Abs(seekPosition - seekTarget) <= 1.0; + + int[] allTrackIds = AudioTracks + .Select(track => track.Track.Ordinal + 1) + .ToArray(); + if (allTrackIds.Length > 0) + PreviewPlayer.SetAudioTracks([allTrackIds[0]]); + if (allTrackIds.Length > 1) + PreviewPlayer.SetAudioTracks(allTrackIds); + bool tracksPassed = PreviewPlayer.IsReady && + PreviewPlayer.DetectedAudioTrackCount == allTrackIds.Length; + + string geometry = "preview window is not ready"; + bool geometryPassed = PreviewPlayer.TryValidateGeometry(out geometry); + string videoOutput = "video output is not ready"; + bool videoOutputPassed = PreviewPlayer.TryValidateVideoOutput(out videoOutput); + await PreviewPlayer.StopAsync(_lifetimeCts.Token); + bool stopPassed = !PreviewPlayer.IsReady; + bool passed = overwriteOverlayPassed && savingOverlayPassed && + seekPassed && tracksPassed && + geometryPassed && videoOutputPassed && stopPassed; + string details = + $"overlay={(overwriteOverlayPassed ? "clear" : "occluded")}, " + + $"saving={(savingOverlayPassed ? "visible" : "hidden")}, " + + $"savingScreenshot={savingScreenshot}, " + + $"{geometry}, {videoOutput}, " + + $"clock={playbackStart:0.000}->{playbackAfter:0.000}" + + $"{(clockAdvanced ? "" : " (startup pending)")}, " + + $"seek={seekPosition:0.000}/{seekTarget:0.000}, " + + $"tracks={PreviewPlayer.DetectedAudioTrackCount}/{allTrackIds.Length}, " + + $"stop={(stopPassed ? "released" : "busy")}"; return (passed, details); } + + private void CaptureVisualToPng(string path) + { + DpiScale dpi = VisualTreeHelper.GetDpi(this); + int width = Math.Max(1, (int)Math.Ceiling(ActualWidth * dpi.DpiScaleX)); + int height = Math.Max(1, (int)Math.Ceiling(ActualHeight * dpi.DpiScaleY)); + var bitmap = new RenderTargetBitmap( + width, + height, + dpi.PixelsPerInchX, + dpi.PixelsPerInchY, + PixelFormats.Pbgra32); + bitmap.Render(this); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using FileStream stream = File.Create(path); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + encoder.Save(stream); + } #endif private async Task LoadTimelineThumbnailsAsync() @@ -162,7 +279,8 @@ private async Task LoadAudioTracksAsync() : Visibility.Collapsed; UpdateMergeAudioState(); - await Task.WhenAll(AudioTracks.Select(LoadWaveformAsync)); + foreach (AudioTrackRow row in AudioTracks) + _ = LoadWaveformAsync(row); } catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) { @@ -198,37 +316,27 @@ private async Task LoadWaveformAsync(AudioTrackRow row) } } - private async Task StartPlaybackAsync(double position) + private async Task InitializePreviewAsync() { if (_playerLoading || _lifetimeCts.IsCancellationRequested) return; _playerLoading = true; PlayButton.IsEnabled = false; - _playbackTimer.Stop(); - _playbackClock.Reset(); - _playing = false; - _playbackPosition = Math.Clamp(position, _selectionStart, _selectionEnd); - UpdatePlayIcon(); - UpdatePlaybackText(); try { - double remaining = Math.Max( - MinimumSelectionSeconds, - _selectionEnd - _playbackPosition); + PreviewLoadingOverlay.Visibility = Visibility.Visible; PreviewPlayer.Visibility = Visibility.Visible; await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render); - TimeSpan startupDelay = await PreviewPlayer.PlayAsync( + PreviewPlayer.Visibility = Visibility.Collapsed; + await PreviewPlayer.LoadAsync( _clip.Path, TimeSpan.FromSeconds(_playbackPosition), - TimeSpan.FromSeconds(remaining), - SelectedAudioStreamIndices(), + SelectedAudioTrackIds(), _lifetimeCts.Token); - _playbackPosition = Math.Min( - _selectionEnd, - _playbackPosition + startupDelay.TotalSeconds); - _playing = true; - _playbackClock.Restart(); - _playbackTimer.Start(); + PreviewImage.Visibility = Visibility.Collapsed; + PreviewLoadingOverlay.Visibility = Visibility.Collapsed; + PreviewPlayer.Visibility = Visibility.Visible; + _playbackPosition = PreviewPlayer.PositionSeconds; EditorStatusText.Text = ""; } catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) @@ -238,17 +346,43 @@ private async Task StartPlaybackAsync(double position) catch (Exception exception) { PreviewPlayer.Visibility = Visibility.Collapsed; + PreviewLoadingOverlay.Visibility = Visibility.Collapsed; + PreviewImage.Visibility = Visibility.Visible; Log.Write($"Clip preview failed ({_clip.Name}): {exception}"); EditorStatusText.Text = exception.Message; } finally { _playerLoading = false; - PlayButton.IsEnabled = !_lifetimeCts.IsCancellationRequested; + PlayButton.IsEnabled = + !_lifetimeCts.IsCancellationRequested && PreviewPlayer.IsReady; UpdatePlayIcon(); + UpdatePlaybackText(); + UpdateTimelineVisual(); } } + private async Task StartPlaybackAsync(double position) + { + if (_playerLoading || _lifetimeCts.IsCancellationRequested) + return; + if (!PreviewPlayer.IsReady) + await InitializePreviewAsync(); + if (!PreviewPlayer.IsReady) + return; + + _playbackPosition = Math.Clamp(position, _selectionStart, _selectionEnd); + PreviewPlayer.SetAudioTracks(SelectedAudioTrackIds()); + PreviewPlayer.Seek(_playbackPosition, exact: true); + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + EditorStatusText.Text = ""; + UpdatePreviewLoadingState(); + UpdatePlayIcon(); + UpdatePlaybackText(); + } + private async void PlayPause_Click(object sender, RoutedEventArgs e) { if (_playerLoading) @@ -264,81 +398,70 @@ private async void PlayPause_Click(object sender, RoutedEventArgs e) await StartPlaybackAsync(start); } - private async Task UpdatePlaybackAsync() + private Task UpdatePlaybackAsync() { double position = CurrentPlaybackPosition(); if (position >= _selectionEnd || !PreviewPlayer.IsReady) { - StopNativePlayback(); + PauseNativePlayback(); _playbackPosition = _selectionStart; + if (PreviewPlayer.IsReady) + PreviewPlayer.Seek(_selectionStart, exact: true); UpdatePlayIcon(); UpdatePlaybackText(); UpdateTimelineVisual(); - await UpdateStillFrameAsync(_playbackPosition); - return; + return Task.CompletedTask; } UpdatePlaybackText(); UpdateTimelineVisual(); + UpdatePreviewLoadingState(); + return Task.CompletedTask; } - private double CurrentPlaybackPosition() => - Math.Min( - _selectionEnd, - _playbackPosition + (_playing ? _playbackClock.Elapsed.TotalSeconds : 0)); + private double CurrentPlaybackPosition() + { + if (PreviewPlayer.IsReady) + _playbackPosition = PreviewPlayer.PositionSeconds; + return Math.Clamp(_playbackPosition, _selectionStart, _selectionEnd); + } - private async Task PausePlaybackAsync(bool updateStill = true) + private Task PausePlaybackAsync() { if (_playing) _playbackPosition = CurrentPlaybackPosition(); - StopNativePlayback(); + PauseNativePlayback(); UpdatePlayIcon(); UpdatePlaybackText(); UpdateTimelineVisual(); - if (updateStill) - await UpdateStillFrameAsync(_playbackPosition); + return Task.CompletedTask; } - private void StopNativePlayback() + private void PauseNativePlayback() { - _playbackClock.Reset(); _playing = false; _playbackTimer.Stop(); - PreviewPlayer.Stop(); - PreviewPlayer.Visibility = Visibility.Collapsed; + PreviewPlayer.Pause(); + UpdatePreviewLoadingState(); } - private async Task UpdateStillFrameAsync(double position) + private void UpdatePreviewLoadingState() { - int request = Interlocked.Increment(ref _stillRequest); - try - { - string? path = await _library.GetPreviewThumbnailAsync( - _rootDirectory, - _clip, - TimeSpan.FromSeconds(position), - _lifetimeCts.Token); - if (request == Volatile.Read(ref _stillRequest) && - path is not null && File.Exists(path) && - !_lifetimeCts.IsCancellationRequested) - { - PreviewImage.Source = LoadBitmap(path, 900); - } - } - catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) - { - // Window is closing. - } - catch (Exception exception) - { - Log.Write($"Preview still generation failed: {exception.Message}"); - } + if (_playerLoading || !PreviewPlayer.IsReady) + return; + bool buffering = _playing && PreviewPlayer.IsBuffering; + PreviewLoadingOverlay.Visibility = buffering + ? Visibility.Visible + : Visibility.Collapsed; + PreviewPlayer.Visibility = buffering + ? Visibility.Collapsed + : Visibility.Visible; } private void PauseForTimelineEdit() { if (_playing) _playbackPosition = CurrentPlaybackPosition(); - StopNativePlayback(); + PauseNativePlayback(); UpdatePlayIcon(); } @@ -350,7 +473,7 @@ private void StartThumb_DragDelta(object sender, DragDeltaEventArgs e) 0, _selectionEnd - MinimumSelectionSeconds); _playbackPosition = _selectionStart; - ShowNearestTimelineFrame(_playbackPosition); + PreviewPlayer.Seek(_playbackPosition, exact: false); UpdateRangeText(); UpdateTimelineVisual(); } @@ -364,18 +487,17 @@ private void EndThumb_DragDelta(object sender, DragDeltaEventArgs e) Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds)); if (_playbackPosition > _selectionEnd) _playbackPosition = _selectionEnd; - ShowNearestTimelineFrame(_playbackPosition); + PreviewPlayer.Seek(_playbackPosition, exact: false); UpdateRangeText(); UpdateTimelineVisual(); } - private async void RangeThumb_DragCompleted(object sender, DragCompletedEventArgs e) + private void RangeThumb_DragCompleted(object sender, DragCompletedEventArgs e) { _playbackPosition = sender == StartThumb ? _selectionStart : _selectionEnd; - ShowNearestTimelineFrame(_playbackPosition); + PreviewPlayer.Seek(_playbackPosition, exact: true); UpdatePlaybackText(); UpdateTimelineVisual(); - await UpdateStillFrameAsync(_playbackPosition); } private void PlayheadThumb_DragStarted(object sender, DragStartedEventArgs e) @@ -390,22 +512,26 @@ private void PlayheadThumb_DragDelta(object sender, DragDeltaEventArgs e) _playbackPosition + PixelsToSeconds(e.HorizontalChange), _selectionStart, _selectionEnd); - ShowNearestTimelineFrame(_playbackPosition); + PreviewPlayer.Seek(_playbackPosition, exact: false); UpdatePlaybackText(); UpdateTimelineVisual(); } - private async void PlayheadThumb_DragCompleted(object sender, DragCompletedEventArgs e) + private void PlayheadThumb_DragCompleted(object sender, DragCompletedEventArgs e) { bool resume = _resumeAfterScrub; _resumeAfterScrub = false; + PreviewPlayer.Seek(_playbackPosition, exact: true); if (resume) - await StartPlaybackAsync(_playbackPosition); - else - await UpdateStillFrameAsync(_playbackPosition); + { + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + UpdatePlayIcon(); + } } - private async void RangeTimeline_MouseLeftButtonDown( + private void RangeTimeline_MouseLeftButtonDown( object sender, MouseButtonEventArgs e) { @@ -421,13 +547,16 @@ private async void RangeTimeline_MouseLeftButtonDown( fraction * Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds), _selectionStart, _selectionEnd); - ShowNearestTimelineFrame(_playbackPosition); + PreviewPlayer.Seek(_playbackPosition, exact: true); UpdatePlaybackText(); UpdateTimelineVisual(); if (resume) - await StartPlaybackAsync(_playbackPosition); - else - await UpdateStillFrameAsync(_playbackPosition); + { + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + UpdatePlayIcon(); + } e.Handled = true; } @@ -437,28 +566,95 @@ private async void Back_Click(object sender, RoutedEventArgs e) => private async void Forward_Click(object sender, RoutedEventArgs e) => await SeekAsync(CurrentPlaybackPosition() + 5); - private async Task SeekAsync(double position) + private Task SeekAsync(double position) { bool resume = _playing; PauseForTimelineEdit(); _playbackPosition = Math.Clamp(position, _selectionStart, _selectionEnd); - ShowNearestTimelineFrame(_playbackPosition); - UpdatePlaybackText(); + PreviewPlayer.Seek(_playbackPosition, exact: true); + UpdatePlaybackText(readPlayerPosition: false); UpdateTimelineVisual(); if (resume) - await StartPlaybackAsync(_playbackPosition); - else - await UpdateStillFrameAsync(_playbackPosition); + { + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + UpdatePlayIcon(); + } + return Task.CompletedTask; + } + + private void FullscreenProgress_DragStarted( + object sender, + DragStartedEventArgs e) + { + ShowFullscreenControls(); + _fullscreenProgressScrubbing = true; + _resumeAfterScrub = _playing; + PauseForTimelineEdit(); + } + + private void FullscreenProgress_ValueChanged( + object sender, + RoutedPropertyChangedEventArgs e) + { + if (_updatingFullscreenProgress || !PreviewPlayer.IsReady) + return; + if (!_fullscreenProgressScrubbing) + { + _ = SeekAsync(e.NewValue); + ShowFullscreenControls(); + return; + } + _playbackPosition = Math.Clamp( + e.NewValue, + _selectionStart, + _selectionEnd); + PreviewPlayer.Seek(_playbackPosition, exact: false); + UpdatePlaybackText(readPlayerPosition: false); + UpdateTimelineVisual(); + ShowFullscreenControls(); + } + + private void FullscreenProgress_DragCompleted( + object sender, + DragCompletedEventArgs e) + { + if (!_fullscreenProgressScrubbing) + return; + bool resume = _resumeAfterScrub; + _resumeAfterScrub = false; + _fullscreenProgressScrubbing = false; + _playbackPosition = Math.Clamp( + FullscreenProgressSlider.Value, + _selectionStart, + _selectionEnd); + PreviewPlayer.Seek(_playbackPosition, exact: true); + UpdatePlaybackText(readPlayerPosition: false); + UpdateTimelineVisual(); + if (resume) + { + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + UpdatePlayIcon(); + } + ShowFullscreenControls(); } - private async void AudioTrackToggle_Click(object sender, RoutedEventArgs e) + private void FullscreenProgress_MouseLeftButtonDown( + object sender, + MouseButtonEventArgs e) + { + ShowFullscreenControls(); + } + + private void AudioTrackToggle_Click(object sender, RoutedEventArgs e) { UpdateMergeAudioState(); - if (!_playing || _playerLoading) + if (_playerLoading || !PreviewPlayer.IsReady) return; - double position = CurrentPlaybackPosition(); - PauseForTimelineEdit(); - await StartPlaybackAsync(position); + PreviewPlayer.SetAudioTracks(SelectedAudioTrackIds()); } private void RangeTimeline_SizeChanged(object sender, SizeChangedEventArgs e) => @@ -495,18 +691,6 @@ private void UpdateTimelineVisual() Math.Clamp(playhead - PlayheadThumb.Width / 2, 0, playheadLimit)); } - private void ShowNearestTimelineFrame(double position) - { - if (_timelineImages.Count == 0) - return; - double duration = Math.Max(MinimumSelectionSeconds, _clip.Duration.TotalSeconds); - int index = Math.Clamp( - (int)(position / duration * _timelineImages.Count), - 0, - _timelineImages.Count - 1); - PreviewImage.Source = _timelineImages[index]; - } - private void UpdateRangeText() { if (StartTimeText is null || EndTimeText is null) @@ -569,20 +753,45 @@ private static string FormatFileSize(long bytes) : $"{bytes / megabyte:0.#} MB"; } - private void UpdatePlaybackText() + private void UpdatePlaybackText(bool readPlayerPosition = true) { if (PlaybackTimeText is null) return; + double position = readPlayerPosition + ? CurrentPlaybackPosition() + : _playbackPosition; PlaybackTimeText.Text = - $"{FormatTime(TimeSpan.FromSeconds(CurrentPlaybackPosition()), false)} / " + + $"{FormatTime(TimeSpan.FromSeconds(position), false)} / " + FormatTime(_clip.Duration, false); + if (FullscreenPlaybackTimeText is not null) + FullscreenPlaybackTimeText.Text = PlaybackTimeText.Text; + if (FullscreenProgressSlider is not null && !_fullscreenProgressScrubbing) + { + _updatingFullscreenProgress = true; + try + { + FullscreenProgressSlider.Minimum = _selectionStart; + FullscreenProgressSlider.Maximum = _selectionEnd; + FullscreenProgressSlider.Value = Math.Clamp( + position, + _selectionStart, + _selectionEnd); + } + finally + { + _updatingFullscreenProgress = false; + } + } } private void UpdatePlayIcon() { if (PlayIcon is null) return; - PlayIcon.Data = (Geometry)FindResource(_playing ? "IconPause" : "IconPlay"); + Geometry icon = (Geometry)FindResource(_playing ? "IconPause" : "IconPlay"); + PlayIcon.Data = icon; + if (FullscreenPlayIcon is not null) + FullscreenPlayIcon.Data = icon; } private IReadOnlyList SelectedAudioStreamIndices() => @@ -591,6 +800,12 @@ private IReadOnlyList SelectedAudioStreamIndices() => .Select(track => track.Track.StreamIndex) .ToArray(); + private IReadOnlyList SelectedAudioTrackIds() => + AudioTracks + .Where(track => track.IsSelected) + .Select(track => track.Track.Ordinal + 1) + .ToArray(); + private void UpdateMergeAudioState() { bool hasSeparateTracks = AudioTracks.Count > 1; @@ -609,6 +824,18 @@ private async void SaveTrim_Click(object sender, RoutedEventArgs e) => private void RequestOverwrite_Click(object sender, RoutedEventArgs e) { + if (OverwriteConfirmOverlay.Visibility == Visibility.Visible) + return; + _resumeAfterOverwriteConfirmation = _playing; + if (_playing) + _playbackPosition = CurrentPlaybackPosition(); + PauseNativePlayback(); + _playerVisibilityBeforeOverwrite = PreviewPlayer.Visibility; + _imageVisibilityBeforeOverwrite = PreviewImage.Visibility; + PreviewPlayer.Visibility = Visibility.Collapsed; + if (PreviewImage.Source is not null) + PreviewImage.Visibility = Visibility.Visible; + OverwriteMessageText.Text = Localization.Text( MergeAudioCheckBox.IsChecked == true ? "L.Library.OverwriteMergeMessage" @@ -620,28 +847,52 @@ private void RequestOverwrite_Click(object sender, RoutedEventArgs e) private void CancelOverwrite_Click(object sender, RoutedEventArgs e) => CancelOverwrite(); - private void CancelOverwrite() => + private void CancelOverwrite(bool resumePlayback = true) + { OverwriteConfirmOverlay.Visibility = Visibility.Collapsed; + PreviewPlayer.Visibility = _playerVisibilityBeforeOverwrite; + PreviewImage.Visibility = _imageVisibilityBeforeOverwrite; + bool resume = resumePlayback && _resumeAfterOverwriteConfirmation && + PreviewPlayer.IsReady; + _resumeAfterOverwriteConfirmation = false; + if (resume) + { + PreviewPlayer.Play(); + _playing = true; + _playbackTimer.Start(); + } + UpdatePlayIcon(); + UpdatePlaybackText(readPlayerPosition: false); + } private async void ConfirmOverwrite_Click(object sender, RoutedEventArgs e) { - CancelOverwrite(); + CancelOverwrite(resumePlayback: false); await SaveTrimAsync(overwrite: true); } private async Task SaveTrimAsync(bool overwrite) { + if (_saveInProgress) + return; + _saveInProgress = true; SaveTrimButton.IsEnabled = false; OverwriteButton.IsEnabled = false; MergeAudioCheckBox.IsEnabled = false; bool mergeAudioTracks = MergeAudioCheckBox.IsChecked == true; - EditorStatusText.Text = Localization.Text( + string savingStatus = Localization.Text( mergeAudioTracks ? "L.Library.TrimmingMerge" : "L.Library.Trimming"); + EditorStatusText.Text = savingStatus; + SavingStatusText.Text = savingStatus; try { - StopNativePlayback(); + PauseNativePlayback(); + PreviewPlayer.Visibility = Visibility.Collapsed; + ShowSavingOverlay(); + await Dispatcher.InvokeAsync(() => { }, DispatcherPriority.Render); + await PreviewPlayer.StopAsync(_lifetimeCts.Token); TimeSpan start = TimeSpan.FromSeconds(_selectionStart); TimeSpan end = TimeSpan.FromSeconds(_selectionEnd); IReadOnlyList audioStreams = SelectedAudioStreamIndices(); @@ -673,13 +924,45 @@ private async Task SaveTrimAsync(bool overwrite) catch (Exception exception) { Log.Write($"Replay trim failed: {exception}"); - EditorStatusText.Text = exception.Message; + HideSavingOverlay(); + EditorStatusText.Text = IsSharingViolation(exception) + ? Localization.Text("L.Library.FileInUse") + : exception.Message; SaveTrimButton.IsEnabled = true; OverwriteButton.IsEnabled = true; UpdateMergeAudioState(); + await InitializePreviewAsync(); + } + finally + { + _saveInProgress = false; } } + private void ShowSavingOverlay() + { + SavingBackdrop.BeginAnimation(OpacityProperty, null); + SavingBackdrop.Opacity = 0; + SavingOverlay.Visibility = Visibility.Visible; + SavingBackdrop.BeginAnimation( + OpacityProperty, + new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(140)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }); + } + + private void HideSavingOverlay() + { + SavingBackdrop.BeginAnimation(OpacityProperty, null); + SavingBackdrop.Opacity = 0; + SavingOverlay.Visibility = Visibility.Collapsed; + } + + private static bool IsSharingViolation(Exception exception) => + exception is IOException && + (exception.HResult & 0xFFFF) is 32 or 33; + private static string AudioLabel(AudioTrackInfo track, int count) { string title = track.Title ?? ""; @@ -702,6 +985,168 @@ private static string AudioLabel(AudioTrackInfo track, int count) return Localization.Format("L.Library.AudioTrackNumber", track.Ordinal + 1); } + private void EnterFullscreen_Click(object sender, RoutedEventArgs e) => + EnterFullscreen(); + + private void ExitFullscreen_Click(object sender, RoutedEventArgs e) => + ExitFullscreen(); + + private void EnterFullscreen() + { + if (_isFullscreen) + return; + + _restoreBounds = new Rect(Left, Top, ActualWidth, ActualHeight); + _restoreWindowState = WindowState; + _restoreTopmost = Topmost; + _isFullscreen = true; + + WindowState = WindowState.Normal; + Rect monitor = CurrentMonitorBounds(); + WindowStartupLocation = WindowStartupLocation.Manual; + Left = monitor.Left; + Top = monitor.Top; + Width = monitor.Width; + Height = monitor.Height; + Topmost = true; + + HeaderRow.Height = new GridLength(0); + EditorHeader.Visibility = Visibility.Collapsed; + EditorWorkspace.Margin = new Thickness(0); + PreviewRow.Height = new GridLength(1, GridUnitType.Star); + PlaybackRow.Height = new GridLength(FullscreenControlsHeight); + TimelineRow.Height = new GridLength(0); + ActionsRow.Height = new GridLength(0); + NormalPlaybackBar.Visibility = Visibility.Collapsed; + WindowChrome.BorderThickness = new Thickness(0); + WindowChrome.CornerRadius = new CornerRadius(0); + PreviewBorder.BorderThickness = new Thickness(0); + PreviewBorder.CornerRadius = new CornerRadius(0); + + _lastPointerActivityUtc = DateTime.UtcNow; + if (GetCursorPos(out NativePoint cursor)) + _lastCursorPosition = cursor; + ShowFullscreenControls(); + _fullscreenUiTimer.Start(); + RefreshFullscreenLayout(); + Focus(); + } + + private void ExitFullscreen() + { + if (!_isFullscreen) + return; + + _isFullscreen = false; + _fullscreenUiTimer.Stop(); + FullscreenControlBar.BeginAnimation(OpacityProperty, null); + FullscreenControlBar.Visibility = Visibility.Collapsed; + FullscreenControlBar.Opacity = 0; + + HeaderRow.Height = new GridLength(56); + EditorHeader.Visibility = Visibility.Visible; + EditorWorkspace.Margin = new Thickness(20, 0, 20, 20); + PreviewRow.Height = new GridLength(390); + PlaybackRow.Height = GridLength.Auto; + TimelineRow.Height = new GridLength(1, GridUnitType.Star); + ActionsRow.Height = GridLength.Auto; + NormalPlaybackBar.Visibility = Visibility.Visible; + WindowChrome.BorderThickness = new Thickness(1); + WindowChrome.CornerRadius = (CornerRadius)FindResource("RadiusWindow"); + PreviewBorder.BorderThickness = new Thickness(1); + PreviewBorder.CornerRadius = new CornerRadius(12); + + Topmost = _restoreTopmost; + WindowState = WindowState.Normal; + Left = _restoreBounds.Left; + Top = _restoreBounds.Top; + Width = _restoreBounds.Width; + Height = _restoreBounds.Height; + WindowState = _restoreWindowState; + EditorWorkspace.UpdateLayout(); + Focus(); + } + + private void FullscreenControls_MouseMove(object sender, MouseEventArgs e) => + ShowFullscreenControls(); + + private void UpdateFullscreenControls() + { + if (!_isFullscreen) + return; + if (GetCursorPos(out NativePoint cursor) && + (cursor.X != _lastCursorPosition.X || cursor.Y != _lastCursorPosition.Y)) + { + _lastCursorPosition = cursor; + ShowFullscreenControls(); + return; + } + if (!FullscreenControlBar.IsMouseOver && + DateTime.UtcNow - _lastPointerActivityUtc >= FullscreenControlsTimeout) + { + HideFullscreenControls(); + } + } + + private void ShowFullscreenControls() + { + if (!_isFullscreen) + return; + _lastPointerActivityUtc = DateTime.UtcNow; + PlaybackRow.Height = new GridLength(FullscreenControlsHeight); + FullscreenControlBar.Visibility = Visibility.Visible; + var animation = new DoubleAnimation(1, TimeSpan.FromMilliseconds(120)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + }; + FullscreenControlBar.BeginAnimation(OpacityProperty, animation); + RefreshFullscreenLayout(); + } + + private void HideFullscreenControls() + { + if (!_isFullscreen || FullscreenControlBar.Visibility != Visibility.Visible) + return; + var animation = new DoubleAnimation(0, TimeSpan.FromMilliseconds(170)); + animation.Completed += (_, _) => + { + if (!_isFullscreen || FullscreenControlBar.IsMouseOver || + DateTime.UtcNow - _lastPointerActivityUtc < FullscreenControlsTimeout) + { + return; + } + FullscreenControlBar.Visibility = Visibility.Collapsed; + PlaybackRow.Height = new GridLength(0); + RefreshFullscreenLayout(); + }; + FullscreenControlBar.BeginAnimation(OpacityProperty, animation); + } + + private void RefreshFullscreenLayout() + { + EditorWorkspace.UpdateLayout(); + _ = Dispatcher.BeginInvoke(DispatcherPriority.Render, () => + { + EditorWorkspace.UpdateLayout(); + PreviewPlayer.InvalidateVisual(); + }); + } + + private Rect CurrentMonitorBounds() + { + nint window = new WindowInteropHelper(this).Handle; + nint monitor = MonitorFromWindow(window, 2); + var info = new MonitorInfo { Size = Marshal.SizeOf() }; + if (monitor == 0 || !GetMonitorInfoW(monitor, ref info)) + return SystemParameters.WorkArea; + + Matrix fromDevice = PresentationSource.FromVisual(this)? + .CompositionTarget?.TransformFromDevice ?? Matrix.Identity; + Point topLeft = fromDevice.Transform(new Point(info.Monitor.Left, info.Monitor.Top)); + Point bottomRight = fromDevice.Transform(new Point(info.Monitor.Right, info.Monitor.Bottom)); + return new Rect(topLeft, bottomRight); + } + private void Header_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (e.ButtonState == MouseButtonState.Pressed) @@ -710,26 +1155,47 @@ private void Header_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) private void Window_PreviewKeyDown(object sender, KeyEventArgs e) { + if (_saveInProgress) + { + e.Handled = true; + return; + } if (e.Key == Key.Escape && OverwriteConfirmOverlay.Visibility == Visibility.Visible) { CancelOverwrite(); e.Handled = true; } + else if (e.Key == Key.Escape && _isFullscreen) + { + ExitFullscreen(); + e.Handled = true; + } else if (e.Key == Key.Escape) Close(); - else if (e.Key == Key.Space) + else if (e.Key == Key.F) { + if (_isFullscreen) + ExitFullscreen(); + else + EnterFullscreen(); + e.Handled = true; + } + else if (e.Key == Key.Space && !e.IsRepeat) + { + ShowFullscreenControls(); PlayPause_Click(PlayButton, new RoutedEventArgs()); e.Handled = true; } else if (e.Key == Key.Left) { + ShowFullscreenControls(); Back_Click(this, new RoutedEventArgs()); e.Handled = true; } else if (e.Key == Key.Right) { + ShowFullscreenControls(); Forward_Click(this, new RoutedEventArgs()); e.Handled = true; } @@ -780,6 +1246,41 @@ private static string FormatTime(TimeSpan time, bool milliseconds) => ? $"{(int)time.TotalMinutes:00}:{time.Seconds:00}.{time.Milliseconds:000}" : $"{(int)time.TotalMinutes:00}:{time.Seconds:00}"; + [StructLayout(LayoutKind.Sequential)] + private struct NativePoint + { + public int X; + public int Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MonitorInfo + { + public int Size; + public NativeRect Monitor; + public NativeRect WorkArea; + public uint Flags; + } + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out NativePoint point); + + [DllImport("user32.dll")] + private static extern nint MonitorFromWindow(nint window, uint flags); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetMonitorInfoW(nint monitor, ref MonitorInfo info); + [DllImport("dwmapi.dll")] private static extern int DwmSetWindowAttribute( nint window, diff --git a/src/Captail/FfmpegAdapter.cs b/src/Captail/FfmpegAdapter.cs index 88016fc..13dbf06 100644 --- a/src/Captail/FfmpegAdapter.cs +++ b/src/Captail/FfmpegAdapter.cs @@ -227,8 +227,8 @@ public async Task TrimCopyAsync( if (start < TimeSpan.Zero || end <= start) throw new ArgumentOutOfRangeException(nameof(start)); - string temporaryPath = destinationPath + $".{Guid.NewGuid():N}.tmp" + - Path.GetExtension(destinationPath); + string outputFormat = OutputFormatForPath(destinationPath); + string temporaryPath = destinationPath + $".{Guid.NewGuid():N}.tmp"; try { int[] selectedAudioStreams = audioStreamIndices? @@ -288,7 +288,7 @@ public async Task TrimCopyAsync( { arguments.AddRange(["-movflags", "+faststart"]); } - arguments.AddRange(["-y", temporaryPath]); + arguments.AddRange(["-f", outputFormat, "-y", temporaryPath]); await RunAsync( _ffmpegPath, arguments, @@ -297,12 +297,14 @@ await RunAsync( if (!File.Exists(temporaryPath) || new FileInfo(temporaryPath).Length == 0) throw new InvalidOperationException("FFmpeg produced an empty clip."); - File.Move(temporaryPath, destinationPath); + await MoveFileWithRetryAsync( + temporaryPath, + destinationPath, + cancellationToken); } finally { - if (File.Exists(temporaryPath)) - File.Delete(temporaryPath); + TryDeleteWorkingFile(temporaryPath); } } @@ -330,16 +332,84 @@ await TrimCopyAsync( mergeAudioTracks, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - File.Replace( + await ReplaceFileWithRetryAsync( replacementPath, sourcePath, - destinationBackupFileName: null, - ignoreMetadataErrors: true); + cancellationToken); } finally { - if (File.Exists(replacementPath)) - File.Delete(replacementPath); + TryDeleteWorkingFile(replacementPath); + } + } + + private static string OutputFormatForPath(string path) => + Path.GetExtension(path).ToLowerInvariant() switch + { + ".mkv" => "matroska", + ".mp4" => "mp4", + ".mov" => "mov", + ".webm" => "webm", + string extension => throw new NotSupportedException( + $"Unsupported replay container '{extension}'."), + }; + + internal static Task MoveFileWithRetryAsync( + string sourcePath, + string destinationPath, + CancellationToken cancellationToken = default) => + RunFileOperationWithRetryAsync( + () => File.Move(sourcePath, destinationPath), + cancellationToken); + + private static Task ReplaceFileWithRetryAsync( + string replacementPath, + string sourcePath, + CancellationToken cancellationToken) => + RunFileOperationWithRetryAsync( + () => File.Replace( + replacementPath, + sourcePath, + destinationBackupFileName: null, + ignoreMetadataErrors: true), + cancellationToken); + + private static async Task RunFileOperationWithRetryAsync( + Action operation, + CancellationToken cancellationToken) + { + const int attempts = 8; + for (int attempt = 1; ; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + operation(); + return; + } + catch (IOException) when (attempt < attempts) + { + await Task.Delay( + TimeSpan.FromMilliseconds(Math.Min(250, 40 * attempt)), + cancellationToken); + } + } + } + + private static void TryDeleteWorkingFile(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException exception) + { + Log.Write($"Temporary replay cleanup deferred ({Path.GetFileName(path)}): {exception.Message}"); + } + catch (UnauthorizedAccessException exception) + { + Log.Write($"Temporary replay cleanup denied ({Path.GetFileName(path)}): {exception.Message}"); } } diff --git a/src/Captail/FfplayHost.cs b/src/Captail/FfplayHost.cs deleted file mode 100644 index 8fe5ecc..0000000 --- a/src/Captail/FfplayHost.cs +++ /dev/null @@ -1,494 +0,0 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Globalization; -using System.IO; -using System.Runtime.InteropServices; -using System.Windows; -using System.Windows.Interop; -using System.Windows.Media; -using System.Windows.Threading; - -namespace Captail; - -[SuppressMessage( - "Usage", - "CA2216:Disposable types should declare finalizer", - Justification = "WPF HwndHost owns the native window and calls DestroyWindowCore on the UI thread.")] -public sealed class FfplayHost : HwndHost -{ - private const int GwlStyle = -16; - private const long WsChild = 0x40000000L; - private const long WsVisible = 0x10000000L; - private const long WsCaption = 0x00C00000L; - private const long WsThickFrame = 0x00040000L; - private const long WsPopup = unchecked((long)0x80000000); - private const string HostWindowClass = "CaptailPreviewHostWindow"; - private const int ErrorClassAlreadyExists = 1410; - private const int BlackBrush = 4; - private const uint SwpNoZOrder = 0x0004; - private const uint SwpNoActivate = 0x0010; - private const uint SwpFrameChanged = 0x0020; - private const int SwHide = 0; - private const int SwShowNoActivate = 4; - private static readonly object HostClassLock = new(); - private static readonly NativeWindowProcedure HostWindowProcedure = HostWindowProc; - private static bool _hostClassRegistered; - - private readonly string _ffplayPath = Path.Combine( - AppContext.BaseDirectory, - "ffmpeg", - "ffplay.exe"); - private readonly List _audioPlayers = []; - private readonly List _drainTasks = []; - private nint _hostHandle; - private nint _playerHandle; - private Process? _player; - private CancellationTokenSource? _attachCts; - private (int Width, int Height) _requestedSize; - - public bool IsReady => _playerHandle != 0 && _player is { HasExited: false }; - - internal bool TryValidateGeometry(out string details) - { - if (_hostHandle == 0 || _playerHandle == 0 || - !GetClientRect(_hostHandle, out NativeRect host) || - !GetClientRect(_playerHandle, out NativeRect player)) - { - details = "preview window is not ready"; - return false; - } - - int hostWidth = host.Right - host.Left; - int hostHeight = host.Bottom - host.Top; - int playerWidth = player.Right - player.Left; - int playerHeight = player.Bottom - player.Top; - details = - $"requested={_requestedSize.Width}x{_requestedSize.Height}, " + - $"host={hostWidth}x{hostHeight}, player={playerWidth}x{playerHeight}"; - return hostWidth > 0 && hostHeight > 0 && - playerWidth == hostWidth && playerHeight == hostHeight; - } - - protected override HandleRef BuildWindowCore(HandleRef hwndParent) - { - EnsureHostWindowClass(); - // Keep native host hidden until FFplay is attached and sized. Otherwise - // Windows paints STATIC's default light background for one frame. - _hostHandle = CreateWindowExW( - 0, - HostWindowClass, - "", - (uint)WsChild, - 0, - 0, - 1, - 1, - hwndParent.Handle, - 0, - 0, - 0); - if (_hostHandle == 0) - throw new InvalidOperationException("Could not create embedded preview host."); - return new HandleRef(this, _hostHandle); - } - - protected override void DestroyWindowCore(HandleRef hwnd) - { - Stop(); - if (hwnd.Handle != 0) - DestroyWindow(hwnd.Handle); - _hostHandle = 0; - } - - protected override void OnWindowPositionChanged(Rect rcBoundingBox) - { - base.OnWindowPositionChanged(rcBoundingBox); - ResizePlayer(); - _ = Dispatcher.BeginInvoke( - DispatcherPriority.Render, - () => ResizePlayer(frameChanged: true)); - } - - protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) - { - base.OnDpiChanged(oldDpi, newDpi); - _ = Dispatcher.BeginInvoke( - DispatcherPriority.Render, - () => ResizePlayer(frameChanged: true)); - } - - public async Task PlayAsync( - string path, - TimeSpan start, - TimeSpan duration, - IReadOnlyList? audioStreamIndices = null, - CancellationToken cancellationToken = default) - { - Stop(); - if (!File.Exists(_ffplayPath)) - throw new FileNotFoundException("Bundled FFplay runtime is unavailable.", _ffplayPath); - - string title = $"CaptailPreview_{Guid.NewGuid():N}"; - (int previewWidth, int previewHeight) = HostClientSize(); - _requestedSize = (previewWidth, previewHeight); - var startInfo = CreateBaseStartInfo(); - AddArguments( - startInfo, - [ - "-noborder", "-autoexit", "-an", - "-x", previewWidth.ToString(CultureInfo.InvariantCulture), - "-y", previewHeight.ToString(CultureInfo.InvariantCulture), - "-left", "-32000", "-top", "-32000", - "-window_title", title, - "-ss", Seconds(start), - "-t", Seconds(TimeSpan.FromSeconds(Math.Max(0.1, duration.TotalSeconds))), - path, - ]); - - var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; - var startupClock = Stopwatch.StartNew(); - if (!process.Start()) - throw new InvalidOperationException("Could not start bundled preview player."); - _player = process; - Task stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); - _drainTasks.Add(process.StandardOutput.ReadToEndAsync(cancellationToken)); - _attachCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - try - { - nint handle = 0; - for (int attempt = 0; attempt < 100 && !process.HasExited; attempt++) - { - _attachCts.Token.ThrowIfCancellationRequested(); - process.Refresh(); - handle = process.MainWindowHandle; - if (handle == 0) - handle = FindWindowW(null, title); - if (handle != 0 && _hostHandle != 0) - break; - await Task.Delay(20, _attachCts.Token); - } - - if (handle == 0 || process.HasExited) - { - string error = await stderrTask; - throw new InvalidOperationException( - string.IsNullOrWhiteSpace(error) - ? "Preview player did not create a video window." - : error.Trim()); - } - - ShowWindow(handle, SwHide); - SetParent(handle, _hostHandle); - long style = GetWindowLongPtrW(handle, GwlStyle).ToInt64(); - style &= ~(WsPopup | WsCaption | WsThickFrame | WsVisible); - style |= WsChild; - SetWindowLongPtrW(handle, GwlStyle, new nint(style)); - _playerHandle = handle; - ResizePlayer(frameChanged: true); - - // Give SDL time to replace its initial blank surface while window is - // still off-screen and hidden. This removes visible startup flash. - await Task.Delay(80, _attachCts.Token); - ResizePlayer(frameChanged: true); - - TimeSpan startupDelay = startupClock.Elapsed; - StartAudioPlayers( - path, - start + startupDelay, - duration - startupDelay, - audioStreamIndices ?? [], - cancellationToken); - - ShowWindow(_hostHandle, SwShowNoActivate); - ShowWindow(_playerHandle, SwShowNoActivate); - ResizePlayer(frameChanged: true); - _ = Dispatcher.BeginInvoke( - DispatcherPriority.Render, - () => ResizePlayer(frameChanged: true)); - return startupDelay; - } - catch - { - Stop(); - throw; - } - } - - public void Stop() - { - _attachCts?.Cancel(); - _attachCts?.Dispose(); - _attachCts = null; - if (_hostHandle != 0) - ShowWindow(_hostHandle, SwHide); - _playerHandle = 0; - - StopProcess(_player); - _player = null; - foreach (Process process in _audioPlayers) - StopProcess(process); - _audioPlayers.Clear(); - _drainTasks.Clear(); - } - - private void StartAudioPlayers( - string path, - TimeSpan start, - TimeSpan duration, - IReadOnlyList streamIndices, - CancellationToken cancellationToken) - { - if (duration <= TimeSpan.Zero) - return; - - foreach (int streamIndex in streamIndices.Distinct()) - { - var startInfo = CreateBaseStartInfo(); - AddArguments( - startInfo, - [ - "-nodisp", "-autoexit", "-vn", - "-ast", streamIndex.ToString(CultureInfo.InvariantCulture), - "-ss", Seconds(start), - "-t", Seconds(duration), - path, - ]); - Process? process = new() { StartInfo = startInfo }; - try - { - if (!process.Start()) - continue; - _audioPlayers.Add(process); - _drainTasks.Add(process.StandardOutput.ReadToEndAsync(cancellationToken)); - _drainTasks.Add(process.StandardError.ReadToEndAsync(cancellationToken)); - process = null; - } - finally - { - process?.Dispose(); - } - } - } - - private ProcessStartInfo CreateBaseStartInfo() - { - var startInfo = new ProcessStartInfo - { - FileName = _ffplayPath, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - }; - AddArguments(startInfo, ["-hide_banner", "-loglevel", "error", "-nostats"]); - return startInfo; - } - - private static void AddArguments(ProcessStartInfo startInfo, IEnumerable arguments) - { - foreach (string argument in arguments) - startInfo.ArgumentList.Add(argument); - } - - private void ResizePlayer(bool frameChanged = false) - { - if (_playerHandle == 0) - return; - (int width, int height) = HostClientSize(); - SetWindowPos( - _playerHandle, - 0, - 0, - 0, - width, - height, - SwpNoZOrder | SwpNoActivate | - (frameChanged ? SwpFrameChanged : 0)); - PostMessageW( - _playerHandle, - 0x0005, - 0, - new nint(((height & 0xFFFF) << 16) | (width & 0xFFFF))); - } - - private (int Width, int Height) HostClientSize() - { - if (_hostHandle != 0 && GetClientRect(_hostHandle, out NativeRect rect)) - { - int width = rect.Right - rect.Left; - int height = rect.Bottom - rect.Top; - if (width > 1 && height > 1) - return (width, height); - } - DpiScale dpi = VisualTreeHelper.GetDpi(this); - return ( - Math.Max(1, (int)Math.Round(ActualWidth * dpi.DpiScaleX)), - Math.Max(1, (int)Math.Round(ActualHeight * dpi.DpiScaleY))); - } - - private static string Seconds(TimeSpan value) => - Math.Max(0, value.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture); - - private static void StopProcess(Process? process) - { - if (process is null) - return; - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(1000); - } - } - catch - { - // Player already exited. - } - finally - { - process.Dispose(); - } - } - - private static void EnsureHostWindowClass() - { - if (_hostClassRegistered) - return; - lock (HostClassLock) - { - if (_hostClassRegistered) - return; - var windowClass = new WindowClassEx - { - Size = (uint)Marshal.SizeOf(), - WindowProcedure = HostWindowProcedure, - Instance = GetModuleHandleW(null), - BackgroundBrush = GetStockObject(BlackBrush), - ClassName = HostWindowClass, - }; - ushort atom = RegisterClassExW(ref windowClass); - int error = Marshal.GetLastWin32Error(); - if (atom == 0 && error != ErrorClassAlreadyExists) - { - throw new InvalidOperationException( - $"Could not register embedded preview host ({error})."); - } - _hostClassRegistered = true; - } - } - - private static nint HostWindowProc( - nint window, - uint message, - nint wParam, - nint lParam) => - DefWindowProcW(window, message, wParam, lParam); - - [UnmanagedFunctionPointer(CallingConvention.Winapi)] - private delegate nint NativeWindowProcedure( - nint window, - uint message, - nint wParam, - nint lParam); - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct WindowClassEx - { - public uint Size; - public uint Style; - public NativeWindowProcedure WindowProcedure; - public int ClassExtra; - public int WindowExtra; - public nint Instance; - public nint Icon; - public nint Cursor; - public nint BackgroundBrush; - public string? MenuName; - public string ClassName; - public nint SmallIcon; - } - - [StructLayout(LayoutKind.Sequential)] - private struct NativeRect - { - public int Left; - public int Top; - public int Right; - public int Bottom; - } - - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern nint CreateWindowExW( - uint extendedStyle, - string className, - string windowName, - uint style, - int x, - int y, - int width, - int height, - nint parent, - nint menu, - nint instance, - nint parameter); - - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern ushort RegisterClassExW(ref WindowClassEx windowClass); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern nint DefWindowProcW( - nint window, - uint message, - nint wParam, - nint lParam); - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] - private static extern nint GetModuleHandleW(string? moduleName); - - [DllImport("gdi32.dll")] - private static extern nint GetStockObject(int objectIndex); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool DestroyWindow(nint window); - - [DllImport("user32.dll", SetLastError = true)] - private static extern nint SetParent(nint child, nint newParent); - - [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)] - private static extern nint GetWindowLongPtrW(nint window, int index); - - [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)] - private static extern nint SetWindowLongPtrW(nint window, int index, nint newValue); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool SetWindowPos( - nint window, - nint insertAfter, - int x, - int y, - int width, - int height, - uint flags); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool GetClientRect(nint window, out NativeRect rect); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool ShowWindow(nint window, int command); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - private static extern bool PostMessageW( - nint window, - uint message, - nint wParam, - nint lParam); - - [DllImport("user32.dll", CharSet = CharSet.Unicode)] - private static extern nint FindWindowW(string? className, string? windowName); -} diff --git a/src/Captail/Interop/CaptureInterop.cs b/src/Captail/Interop/CaptureInterop.cs index 67eac49..919973c 100644 --- a/src/Captail/Interop/CaptureInterop.cs +++ b/src/Captail/Interop/CaptureInterop.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Diagnostics; using System.Runtime.InteropServices; namespace Captail.Interop; @@ -23,6 +25,14 @@ private static extern bool EnumDisplayDevices( ref DisplayDevice displayDevice, uint flags); + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId( + nint window, + out uint processId); + [StructLayout(LayoutKind.Sequential)] private struct Rect { @@ -109,4 +119,21 @@ public static List EnumerateMonitors() return monitors; } + public static string ForegroundExecutable() + { + nint window = GetForegroundWindow(); + if (window == 0 || GetWindowThreadProcessId(window, out uint processId) == 0) + return ""; + try + { + using Process process = Process.GetProcessById((int)processId); + return process.ProcessName + ".exe"; + } + catch (Exception exception) when ( + exception is ArgumentException or InvalidOperationException or Win32Exception) + { + return ""; + } + } + } diff --git a/src/Captail/Languages/Strings.en.xaml b/src/Captail/Languages/Strings.en.xaml index 9707d34..0c39022 100644 --- a/src/Captail/Languages/Strings.en.xaml +++ b/src/Captail/Languages/Strings.en.xaml @@ -139,7 +139,9 @@ Detected games save into their own folders. Replays saved while Desktop is active stay in the main folder. RECENT REPLAYS - Saved replays will appear here. + No replays yet. Saved replays will appear here. + Loading replays… + Could not load replays. Refresh replays Show in folder Trim @@ -153,9 +155,13 @@ Start End Play / pause + Full screen + Exit full screen Save trimmed copy Saving clip… Trimming and mixing audio… + Saving replay + Replay is still in use. Close external players and try again. Creates a new file without re-encoding. Original replay stays untouched. Trimmed replay saved VIDEO diff --git a/src/Captail/Languages/Strings.ru.xaml b/src/Captail/Languages/Strings.ru.xaml index 134954f..9708b76 100644 --- a/src/Captail/Languages/Strings.ru.xaml +++ b/src/Captail/Languages/Strings.ru.xaml @@ -139,7 +139,9 @@ Найденные игры сохраняются в свои папки. Повторы, сохранённые во время записи рабочего стола, остаются в основной папке. ПОСЛЕДНИЕ ПОВТОРЫ - Сохранённые повторы появятся здесь. + Повторов пока нет. Сохранённые повторы появятся здесь. + Загрузка повторов… + Не удалось загрузить повторы. Обновить список Показать в папке Обрезать @@ -153,9 +155,13 @@ Начало Конец Воспроизвести / пауза + На весь экран + Выйти из полноэкранного режима Сохранить копию Сохраняю клип… Обрезаю и объединяю звук… + Сохранение повтора + Файл повтора всё ещё используется. Закройте внешние проигрыватели и попробуйте снова. Создаёт новый файл без перекодирования. Исходный повтор останется нетронутым. Обрезанный повтор сохранён ВИДЕО diff --git a/src/Captail/MpvHost.cs b/src/Captail/MpvHost.cs new file mode 100644 index 0000000..910306a --- /dev/null +++ b/src/Captail/MpvHost.cs @@ -0,0 +1,824 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Threading; + +namespace Captail; + +[SuppressMessage( + "Usage", + "CA2216:Disposable types should declare finalizer", + Justification = "WPF HwndHost owns the native window and calls DestroyWindowCore on the UI thread.")] +public sealed class MpvHost : HwndHost +{ + private const string HostWindowClass = "CaptailMpvHostWindow"; + private const int ErrorClassAlreadyExists = 1410; + private const int BlackBrush = 4; + private const uint WsChild = 0x40000000; + private const uint WsVisible = 0x10000000; + private const uint SwpNoZOrder = 0x0004; + private const uint SwpNoActivate = 0x0010; + private static readonly object HostClassLock = new(); + private static readonly NativeWindowProcedure HostWindowProcedure = HostWindowProc; + private static bool _hostClassRegistered; + + private readonly object _stateLock = new(); + private nint _hostHandle; + private nint _mpvHandle; + private CancellationTokenSource? _eventCancellation; + private Task? _eventTask; + private TaskCompletionSource? _fileLoadedCompletion; + private TaskCompletionSource? _fileStoppedCompletion; + private bool _fileLoaded; + private bool _disposed; + private double _lastPosition; + private int _videoTrackId; + private int[] _audioTrackIds = []; + private (int Width, int Height) _requestedSize; + + public bool IsReady => !_disposed && _fileLoaded && _mpvHandle != 0; + + public bool IsBuffering => IsReady && + (string.Equals( + MpvNative.GetPropertyStringValue(_mpvHandle, "paused-for-cache"), + "yes", + StringComparison.Ordinal) || + string.Equals( + MpvNative.GetPropertyStringValue(_mpvHandle, "seeking"), + "yes", + StringComparison.Ordinal)); + +#if DEBUG + internal int DetectedAudioTrackCount => _audioTrackIds.Length; + + internal bool TryValidateVideoOutput(out string details) + { + string Value(string name) => + MpvNative.GetPropertyStringValue(_mpvHandle, name) ?? ""; + string videoId = Value("vid"); + string codec = Value("video-codec"); + string hardwareDecoder = Value("hwdec-current"); + string videoOutput = Value("current-vo"); + bool hasWidth = TryGetInt64("video-out-params/w", out long width) && width > 0; + bool hasHeight = TryGetInt64("video-out-params/h", out long height) && height > 0; + details = + $"vid={videoId}, codec={codec}, hwdec={hardwareDecoder}, " + + $"vo={videoOutput}, size={width}x{height}"; + return videoId != "no" && codec != "" && + videoOutput != "" && hasWidth && hasHeight; + } +#endif + + public double PositionSeconds + { + get + { + if (TryGetDouble("time-pos", out double position) && + double.IsFinite(position) && position >= 0) + { + _lastPosition = position; + } + return _lastPosition; + } + } + + internal bool TryValidateGeometry(out string details) + { + if (_hostHandle == 0 || !GetClientRect(_hostHandle, out NativeRect host)) + { + details = "preview host is not ready"; + return false; + } + + int hostWidth = host.Right - host.Left; + int hostHeight = host.Bottom - host.Top; + nint videoWindow = FindWindowExW(_hostHandle, 0, null, null); + if (videoWindow != 0 && GetClientRect(videoWindow, out NativeRect video)) + { + int videoWidth = video.Right - video.Left; + int videoHeight = video.Bottom - video.Top; + details = + $"requested={_requestedSize.Width}x{_requestedSize.Height}, " + + $"host={hostWidth}x{hostHeight}, video={videoWidth}x{videoHeight}"; + return IsReady && hostWidth > 0 && hostHeight > 0 && + videoWidth == hostWidth && videoHeight == hostHeight; + } + + details = + $"requested={_requestedSize.Width}x{_requestedSize.Height}, " + + $"host={hostWidth}x{hostHeight}, direct-render=yes"; + return IsReady && hostWidth > 0 && hostHeight > 0; + } + + protected override HandleRef BuildWindowCore(HandleRef hwndParent) + { + EnsureHostWindowClass(); + _hostHandle = CreateWindowExW( + 0, + HostWindowClass, + "", + WsChild | WsVisible, + 0, + 0, + 1, + 1, + hwndParent.Handle, + 0, + 0, + 0); + if (_hostHandle == 0) + throw new InvalidOperationException("Could not create embedded preview host."); + + try + { + InitializePlayer(); + } + catch + { + DestroyWindow(_hostHandle); + _hostHandle = 0; + throw; + } + return new HandleRef(this, _hostHandle); + } + + protected override void DestroyWindowCore(HandleRef hwnd) + { + Shutdown(); + if (hwnd.Handle != 0) + DestroyWindow(hwnd.Handle); + _hostHandle = 0; + } + + protected override void OnWindowPositionChanged(Rect rcBoundingBox) + { + base.OnWindowPositionChanged(rcBoundingBox); + ResizeVideoWindow(); + _ = Dispatcher.BeginInvoke(DispatcherPriority.Render, ResizeVideoWindow); + } + + protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) + { + base.OnDpiChanged(oldDpi, newDpi); + _ = Dispatcher.BeginInvoke(DispatcherPriority.Render, ResizeVideoWindow); + } + + public async Task LoadAsync( + string path, + TimeSpan start, + IReadOnlyList audioTrackIds, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!File.Exists(path)) + throw new FileNotFoundException("Replay file is unavailable.", path); + EnsureInitialized(); + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + lock (_stateLock) + { + _fileLoaded = false; + _fileLoadedCompletion = completion; + } + + SetProperty("pause", "yes"); + SetProperty("start", Seconds(start)); + Command("loadfile", path, "replace"); + + try + { + await completion.Task.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken); + } + catch + { + lock (_stateLock) + { + if (ReferenceEquals(_fileLoadedCompletion, completion)) + _fileLoadedCompletion = null; + } + throw; + } + + RefreshTrackIds(); + if (_videoTrackId <= 0) + throw new InvalidOperationException("Replay does not contain a video track."); + SetProperty("vid", _videoTrackId.ToString(CultureInfo.InvariantCulture)); + SetAudioTracks(audioTrackIds); + Seek(start.TotalSeconds, exact: true); + Pause(); + ResizeVideoWindow(); + await WaitForVideoOutputAsync(cancellationToken); + } + + public void Play() + { + if (!IsReady) + return; + SetProperty("pause", "no"); + } + + public void Pause() + { + if (!IsReady) + return; + _lastPosition = PositionSeconds; + SetProperty("pause", "yes"); + } + + public void Seek(double positionSeconds, bool exact) + { + if (!IsReady) + return; + double position = Math.Max(0, positionSeconds); + _lastPosition = position; + Command( + "seek", + position.ToString("0.###", CultureInfo.InvariantCulture), + exact ? "absolute+exact" : "absolute+keyframes"); + } + + public void SetAudioTracks(IReadOnlyList audioTrackOrdinals) + { + if (!IsReady) + return; + + int[] ids = audioTrackOrdinals + .Where(ordinal => ordinal > 0) + .Select(ordinal => ordinal <= _audioTrackIds.Length + ? _audioTrackIds[ordinal - 1] + : ordinal) + .Distinct() + .Order() + .ToArray(); + SetProperty("lavfi-complex", ""); + if (ids.Length == 0) + { + SetProperty("aid", "no"); + return; + } + if (ids.Length == 1) + { + SetProperty("aid", ids[0].ToString(CultureInfo.InvariantCulture)); + return; + } + + SetProperty("aid", "no"); + string inputs = string.Concat(ids.Select(id => $"[aid{id}]")); + string graph = + $"{inputs}amix=inputs={ids.Length}:normalize=0:dropout_transition=0," + + "alimiter=limit=0.95:level=disabled[ao]"; + SetProperty("lavfi-complex", graph); + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + if (!IsReady) + return; + _lastPosition = PositionSeconds; + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + lock (_stateLock) + _fileStoppedCompletion = completion; + Command("stop"); + try + { + await completion.Task.WaitAsync(TimeSpan.FromSeconds(3), cancellationToken); + } + finally + { + lock (_stateLock) + { + if (ReferenceEquals(_fileStoppedCompletion, completion)) + _fileStoppedCompletion = null; + } + } + } + + public void Shutdown() + { + if (_disposed) + return; + _disposed = true; + + CancellationTokenSource? cancellation = _eventCancellation; + Task? eventTask = _eventTask; + cancellation?.Cancel(); + if (_mpvHandle != 0) + MpvNative.Wakeup(_mpvHandle); + try + { + eventTask?.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException) + { + // Event loop is being cancelled during normal window teardown. + } + + lock (_stateLock) + { + _fileLoaded = false; + _fileLoadedCompletion?.TrySetCanceled(); + _fileLoadedCompletion = null; + _fileStoppedCompletion?.TrySetCanceled(); + _fileStoppedCompletion = null; + } + if (_mpvHandle != 0) + { + MpvNative.TerminateDestroy(_mpvHandle); + _mpvHandle = 0; + } + cancellation?.Dispose(); + _eventCancellation = null; + _eventTask = null; + } + + private void InitializePlayer() + { + _mpvHandle = MpvNative.Create(); + if (_mpvHandle == 0) + throw new InvalidOperationException("Could not create libmpv playback context."); + + try + { + SetOption("wid", _hostHandle.ToInt64().ToString(CultureInfo.InvariantCulture)); + SetOption("config", "no"); + SetOption("load-scripts", "no"); + SetOption("terminal", "no"); + SetOption("msg-level", "all=no"); + SetOption("input-default-bindings", "no"); + SetOption("input-vo-keyboard", "no"); + SetOption("input-cursor", "no"); + SetOption("osc", "no"); + SetOption("idle", "yes"); + SetOption("keep-open", "yes"); + SetOption("force-window", "no"); + SetOption("profile", "fast"); + SetOption("vo", "gpu-next"); + SetOption("gpu-api", "d3d11"); + SetOption("gpu-context", "d3d11"); + SetOption("hwdec", "auto-safe"); + SetOption("video-sync", "audio"); + SetOption("interpolation", "no"); + SetOption("hr-seek", "yes"); + SetOption("hr-seek-framedrop", "yes"); + SetOption("track-auto-selection", "no"); + SetOption("audio-exclusive", "no"); + SetOption("audio-client-name", "Captail"); + SetOption("background-color", "#070A0C"); + + int result = MpvNative.Initialize(_mpvHandle); + ThrowOnError(result, "Could not initialize libmpv"); + _eventCancellation = new CancellationTokenSource(); + _eventTask = Task.Factory.StartNew( + () => ProcessEvents(_eventCancellation.Token), + _eventCancellation.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + catch + { + MpvNative.TerminateDestroy(_mpvHandle); + _mpvHandle = 0; + throw; + } + } + + private void ProcessEvents(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested && _mpvHandle != 0) + { + nint eventPointer = MpvNative.WaitEvent(_mpvHandle, 0.25); + if (eventPointer == 0) + continue; + MpvEvent playerEvent = Marshal.PtrToStructure(eventPointer); + switch (playerEvent.EventId) + { + case MpvEventId.FileLoaded: + lock (_stateLock) + { + _fileLoaded = true; + _fileLoadedCompletion?.TrySetResult(true); + _fileLoadedCompletion = null; + } + _ = Dispatcher.BeginInvoke(DispatcherPriority.Render, ResizeVideoWindow); + break; + + case MpvEventId.EndFile: + HandleEndFile(playerEvent.Data); + break; + + case MpvEventId.VideoReconfig: + _ = Dispatcher.BeginInvoke(DispatcherPriority.Render, ResizeVideoWindow); + break; + + case MpvEventId.Shutdown: + return; + } + } + } + + private void HandleEndFile(nint data) + { + MpvEventEndFile end = data == 0 + ? default + : Marshal.PtrToStructure(data); + lock (_stateLock) + { + _fileLoaded = false; + _fileStoppedCompletion?.TrySetResult(true); + _fileStoppedCompletion = null; + if (end.Reason == MpvEndFileReason.Error) + { + _fileLoadedCompletion?.TrySetException( + new InvalidOperationException( + $"libmpv could not load replay: {MpvNative.ErrorText(end.Error)}")); + } + _fileLoadedCompletion = null; + } + } + + private void SetOption(string name, string value) => + ThrowOnError(MpvNative.SetOptionString(_mpvHandle, name, value), $"libmpv option '{name}'"); + + private void SetProperty(string name, string value) => + ThrowOnError(MpvNative.SetPropertyString(_mpvHandle, name, value), $"libmpv property '{name}'"); + + private bool TryGetDouble(string name, out double value) + { + value = 0; + return _mpvHandle != 0 && !_disposed && + MpvNative.GetPropertyDouble( + _mpvHandle, + name, + MpvFormat.Double, + out value) >= 0; + } + + private void RefreshTrackIds() + { + if (!TryGetInt64("track-list/count", out long count) || count <= 0) + { + _videoTrackId = 0; + _audioTrackIds = []; + return; + } + + _videoTrackId = 0; + var ids = new List(); + for (int index = 0; index < count; index++) + { + string prefix = $"track-list/{index}"; + string? type = MpvNative.GetPropertyStringValue(_mpvHandle, $"{prefix}/type"); + if (!TryGetInt64($"{prefix}/id", out long id) || + id is <= 0 or > int.MaxValue) + { + continue; + } + if (string.Equals(type, "video", StringComparison.Ordinal) && + _videoTrackId == 0) + { + _videoTrackId = (int)id; + } + else if (string.Equals(type, "audio", StringComparison.Ordinal)) + { + ids.Add((int)id); + } + } + _audioTrackIds = ids.ToArray(); + } + + private bool TryGetInt64(string name, out long value) + { + value = 0; + return _mpvHandle != 0 && !_disposed && + MpvNative.GetPropertyInt64( + _mpvHandle, + name, + MpvFormat.Int64, + out value) >= 0; + } + + private async Task WaitForVideoOutputAsync(CancellationToken cancellationToken) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + bool hasWidth = TryGetInt64("video-out-params/w", out long width) && width > 0; + bool hasHeight = TryGetInt64("video-out-params/h", out long height) && height > 0; + if (hasWidth && hasHeight) + return; + await Task.Delay(50, cancellationToken); + } + throw new TimeoutException("libmpv video output did not initialize in time."); + } + + private void Command(params string[] arguments) + { + EnsureInitialized(); + nint[] pointers = new nint[arguments.Length + 1]; + GCHandle pinned = default; + try + { + for (int index = 0; index < arguments.Length; index++) + pointers[index] = Marshal.StringToCoTaskMemUTF8(arguments[index]); + pinned = GCHandle.Alloc(pointers, GCHandleType.Pinned); + ThrowOnError( + MpvNative.Command(_mpvHandle, pinned.AddrOfPinnedObject()), + $"libmpv command '{arguments.FirstOrDefault()}'"); + } + finally + { + if (pinned.IsAllocated) + pinned.Free(); + foreach (nint pointer in pointers) + { + if (pointer != 0) + Marshal.FreeCoTaskMem(pointer); + } + } + } + + private void EnsureInitialized() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_mpvHandle == 0) + throw new InvalidOperationException("Embedded preview player is not initialized."); + } + + private static void ThrowOnError(int result, string operation) + { + if (result < 0) + throw new InvalidOperationException($"{operation}: {MpvNative.ErrorText(result)}"); + } + + private void ResizeVideoWindow() + { + if (_hostHandle == 0) + return; + (int width, int height) = HostClientSize(); + _requestedSize = (width, height); + nint videoWindow = FindWindowExW(_hostHandle, 0, null, null); + if (videoWindow != 0) + { + SetWindowPos( + videoWindow, + 0, + 0, + 0, + width, + height, + SwpNoZOrder | SwpNoActivate); + } + } + + private (int Width, int Height) HostClientSize() + { + if (_hostHandle != 0 && GetClientRect(_hostHandle, out NativeRect rect)) + { + int width = rect.Right - rect.Left; + int height = rect.Bottom - rect.Top; + if (width > 1 && height > 1) + return (width, height); + } + DpiScale dpi = VisualTreeHelper.GetDpi(this); + return ( + Math.Max(1, (int)Math.Round(ActualWidth * dpi.DpiScaleX)), + Math.Max(1, (int)Math.Round(ActualHeight * dpi.DpiScaleY))); + } + + private static string Seconds(TimeSpan value) => + Math.Max(0, value.TotalSeconds).ToString("0.###", CultureInfo.InvariantCulture); + + private static void EnsureHostWindowClass() + { + if (_hostClassRegistered) + return; + lock (HostClassLock) + { + if (_hostClassRegistered) + return; + var windowClass = new WindowClassEx + { + Size = (uint)Marshal.SizeOf(), + WindowProcedure = HostWindowProcedure, + Instance = GetModuleHandleW(null), + BackgroundBrush = GetStockObject(BlackBrush), + ClassName = HostWindowClass, + }; + ushort atom = RegisterClassExW(ref windowClass); + int error = Marshal.GetLastWin32Error(); + if (atom == 0 && error != ErrorClassAlreadyExists) + { + throw new InvalidOperationException( + $"Could not register preview host window class ({error})."); + } + _hostClassRegistered = true; + } + } + + private static nint HostWindowProc(nint window, uint message, nint wParam, nint lParam) => + DefWindowProcW(window, message, wParam, lParam); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct WindowClassEx + { + public uint Size; + public uint Style; + public NativeWindowProcedure? WindowProcedure; + public int ClassExtra; + public int WindowExtra; + public nint Instance; + public nint Icon; + public nint Cursor; + public nint BackgroundBrush; + public string? MenuName; + public string? ClassName; + public nint SmallIcon; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NativeRect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct MpvEvent + { + public readonly MpvEventId EventId; + public readonly int Error; + public readonly ulong ReplyUserData; + public readonly nint Data; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct MpvEventEndFile + { + public readonly MpvEndFileReason Reason; + public readonly int Error; + public readonly long PlaylistEntryId; + public readonly long PlaylistInsertId; + public readonly int PlaylistInsertCount; + } + + private enum MpvEventId + { + None = 0, + Shutdown = 1, + EndFile = 7, + FileLoaded = 8, + VideoReconfig = 17, + } + + private enum MpvEndFileReason + { + Eof = 0, + Stop = 2, + Quit = 3, + Error = 4, + Redirect = 5, + } + + private enum MpvFormat + { + Int64 = 4, + Double = 5, + } + + private static class MpvNative + { + private const string Library = "libmpv-2.dll"; + + [DllImport(Library, EntryPoint = "mpv_create", CallingConvention = CallingConvention.Cdecl)] + internal static extern nint Create(); + + [DllImport(Library, EntryPoint = "mpv_initialize", CallingConvention = CallingConvention.Cdecl)] + internal static extern int Initialize(nint handle); + + [DllImport(Library, EntryPoint = "mpv_terminate_destroy", CallingConvention = CallingConvention.Cdecl)] + internal static extern void TerminateDestroy(nint handle); + + [DllImport(Library, EntryPoint = "mpv_set_option_string", CallingConvention = CallingConvention.Cdecl)] + internal static extern int SetOptionString( + nint handle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + [MarshalAs(UnmanagedType.LPUTF8Str)] string value); + + [DllImport(Library, EntryPoint = "mpv_set_property_string", CallingConvention = CallingConvention.Cdecl)] + internal static extern int SetPropertyString( + nint handle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + [MarshalAs(UnmanagedType.LPUTF8Str)] string value); + + [DllImport(Library, EntryPoint = "mpv_get_property", CallingConvention = CallingConvention.Cdecl)] + internal static extern int GetPropertyDouble( + nint handle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + MpvFormat format, + out double value); + + [DllImport(Library, EntryPoint = "mpv_get_property", CallingConvention = CallingConvention.Cdecl)] + internal static extern int GetPropertyInt64( + nint handle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + MpvFormat format, + out long value); + + [DllImport(Library, EntryPoint = "mpv_get_property_string", CallingConvention = CallingConvention.Cdecl)] + private static extern nint GetPropertyString( + nint handle, + [MarshalAs(UnmanagedType.LPUTF8Str)] string name); + + [DllImport(Library, EntryPoint = "mpv_free", CallingConvention = CallingConvention.Cdecl)] + private static extern void Free(nint data); + + internal static string? GetPropertyStringValue(nint handle, string name) + { + nint value = GetPropertyString(handle, name); + if (value == 0) + return null; + try + { + return Marshal.PtrToStringUTF8(value); + } + finally + { + Free(value); + } + } + + [DllImport(Library, EntryPoint = "mpv_command", CallingConvention = CallingConvention.Cdecl)] + internal static extern int Command(nint handle, nint arguments); + + [DllImport(Library, EntryPoint = "mpv_wait_event", CallingConvention = CallingConvention.Cdecl)] + internal static extern nint WaitEvent(nint handle, double timeout); + + [DllImport(Library, EntryPoint = "mpv_wakeup", CallingConvention = CallingConvention.Cdecl)] + internal static extern void Wakeup(nint handle); + + [DllImport(Library, EntryPoint = "mpv_error_string", CallingConvention = CallingConvention.Cdecl)] + private static extern nint ErrorString(int error); + + internal static string ErrorText(int error) => + Marshal.PtrToStringUTF8(ErrorString(error)) ?? $"error {error}"; + } + + private delegate nint NativeWindowProcedure(nint window, uint message, nint wParam, nint lParam); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern ushort RegisterClassExW(ref WindowClassEx windowClass); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern nint CreateWindowExW( + uint extendedStyle, + string className, + string windowName, + uint style, + int x, + int y, + int width, + int height, + nint parent, + nint menu, + nint instance, + nint parameter); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyWindow(nint window); + + [DllImport("user32.dll")] + private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam); + + [DllImport("user32.dll")] + private static extern nint FindWindowExW(nint parent, nint childAfter, string? className, string? windowName); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetClientRect(nint window, out NativeRect rectangle); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetWindowPos( + nint window, + nint insertAfter, + int x, + int y, + int width, + int height, + uint flags); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern nint GetModuleHandleW(string? moduleName); + + [DllImport("gdi32.dll")] + private static extern nint GetStockObject(int objectIndex); +} diff --git a/src/Captail/ObsPluginDataCache.cs b/src/Captail/ObsPluginDataCache.cs new file mode 100644 index 0000000..79364fb --- /dev/null +++ b/src/Captail/ObsPluginDataCache.cs @@ -0,0 +1,211 @@ +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace Captail; + +internal static class ObsPluginDataCache +{ + private const string CacheDirectoryName = "obs-plugin-cache"; + + public static string Prepare(string packagedDataRoot) + { + string sourceRoot = Path.Combine(packagedDataRoot, "obs-plugins"); + if (!Directory.Exists(sourceRoot)) + throw new DirectoryNotFoundException(sourceRoot); + + string cacheRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Captail", + CacheDirectoryName); + Directory.CreateDirectory(cacheRoot); + + string cacheKey = ComputeTreeHash(sourceRoot)[..20].ToLowerInvariant(); + string destinationRoot = Path.Combine(cacheRoot, cacheKey); + string destinationPlugins = Path.Combine(destinationRoot, "obs-plugins"); + if (!IsExactCopy(sourceRoot, destinationPlugins)) + { + if (Directory.Exists(destinationRoot) && !TryDelete(destinationRoot)) + { + destinationRoot = Path.Combine( + cacheRoot, + $"{cacheKey}-{Guid.NewGuid():N}"); + destinationPlugins = Path.Combine(destinationRoot, "obs-plugins"); + } + + CopyAtomically(sourceRoot, destinationRoot, destinationPlugins); + } + + CleanupStaleCaches(cacheRoot, destinationRoot); + return destinationRoot; + } + + private static void CopyAtomically( + string sourceRoot, + string destinationRoot, + string destinationPlugins) + { + string stagingRoot = Path.Combine( + Path.GetDirectoryName(destinationRoot)!, + $".staging-{Environment.ProcessId}-{Guid.NewGuid():N}"); + string stagingPlugins = Path.Combine(stagingRoot, "obs-plugins"); + try + { + CopyDirectory(sourceRoot, stagingPlugins); + try + { + Directory.Move(stagingRoot, destinationRoot); + } + catch (IOException) when (IsExactCopy(sourceRoot, destinationPlugins)) + { + TryDelete(stagingRoot); + } + } + finally + { + TryDelete(stagingRoot); + } + + if (!IsExactCopy(sourceRoot, destinationPlugins)) + { + throw new IOException( + "OBS plugin data cache could not be prepared safely."); + } + } + + private static void CopyDirectory(string sourceRoot, string destinationRoot) + { + foreach (string sourceDirectory in Directory.EnumerateDirectories( + sourceRoot, + "*", + SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(sourceRoot, sourceDirectory); + Directory.CreateDirectory(Path.Combine(destinationRoot, relative)); + } + + Directory.CreateDirectory(destinationRoot); + foreach (string sourceFile in Directory.EnumerateFiles( + sourceRoot, + "*", + SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(sourceRoot, sourceFile); + string destinationFile = Path.Combine(destinationRoot, relative); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + File.Copy(sourceFile, destinationFile, overwrite: false); + } + } + + private static bool IsExactCopy(string sourceRoot, string destinationRoot) + { + try + { + if (!Directory.Exists(destinationRoot)) + return false; + + string[] sourceFiles = RelativeFiles(sourceRoot); + string[] destinationFiles = RelativeFiles(destinationRoot); + if (!sourceFiles.SequenceEqual( + destinationFiles, + StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + foreach (string relative in sourceFiles) + { + string source = Path.Combine(sourceRoot, relative); + string destination = Path.Combine(destinationRoot, relative); + var sourceInfo = new FileInfo(source); + var destinationInfo = new FileInfo(destination); + if (sourceInfo.Length != destinationInfo.Length || + !FileHashesMatch(source, destination)) + { + return false; + } + } + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + + private static string[] RelativeFiles(string root) => + Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(root, path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool FileHashesMatch(string first, string second) + { + byte[] firstHash = HashFile(first); + byte[] secondHash = HashFile(second); + return CryptographicOperations.FixedTimeEquals(firstHash, secondHash); + } + + private static string ComputeTreeHash(string root) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (string relative in RelativeFiles(root)) + { + hash.AppendData(Encoding.UTF8.GetBytes(relative.Replace('\\', '/') + "\0")); + hash.AppendData(HashFile(Path.Combine(root, relative))); + } + return Convert.ToHexString(hash.GetHashAndReset()); + } + + private static byte[] HashFile(string path) + { + using FileStream stream = new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + return SHA256.HashData(stream); + } + + private static void CleanupStaleCaches(string cacheRoot, string activeRoot) + { + foreach (string directory in Directory.EnumerateDirectories(cacheRoot)) + { + if (string.Equals( + directory, + activeRoot, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + TryDelete(directory); + } + } + + private static bool TryDelete(string directory) + { + if (!Directory.Exists(directory)) + return true; + + try + { + Directory.Delete(directory, recursive: true); + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/src/Captail/ObsReplayEngine.cs b/src/Captail/ObsReplayEngine.cs index 8ac6811..8f9fbe5 100644 --- a/src/Captail/ObsReplayEngine.cs +++ b/src/Captail/ObsReplayEngine.cs @@ -12,7 +12,21 @@ namespace Captail; public sealed class ObsReplayEngine : IDisposable { private const string RequiredObsVersion = "32.1.2"; + private const int AutomaticHookStableChecks = 2; private static readonly string[] CapabilityCodecNames = ["h264", "hevc", "av1"]; + private static readonly HashSet AutomaticCaptureRejectedProcesses = new( + [ + "applicationframehost", "brave", "captail", "chrome", "discord", + "discordcanary", "discordptb", "dwm", "eadesktop", "epicgameslauncher", + "explorer", "firefox", "galaxyclient", "lockapp", "livelywpf", + "mediaplayer", "mpv", "ms-teams", "msedge", "nvcontainer", "obs32", + "obs64", "opera", "opera_gx", "searchapp", "searchhost", + "shellexperiencehost", "signal", "slack", "spotify", "startmenuexperiencehost", + "steam", "steamwebhelper", "systemsettings", "telegram", "textinputhost", + "ubisoftconnect", "vivaldi", "vlc", "wallpaper32", "wallpaper64", + "wallpaper_engine", "webviewhost", "whatsapp", "wmplayer", "zoom", + ], + StringComparer.OrdinalIgnoreCase); private static readonly string[] DiagnosticEffectNames = [ "default.effect", "opaque.effect", "solid.effect", @@ -51,6 +65,9 @@ public sealed class ObsReplayEngine : IDisposable private bool _automaticGameActive; private bool _automaticGameSourceShowing; private string _activeGameExecutable = ""; + private string _pendingAutomaticGameExecutable = ""; + private int _automaticHookStableChecks; + private string _lastRejectedAutomaticExecutable = ""; public event Action? Faulted; @@ -103,6 +120,24 @@ public string Description public string ActiveGameExecutable => _activeGameExecutable; + internal static bool IsAutomaticCaptureCandidate(string executable) + { + string processName = Path.GetFileNameWithoutExtension(executable.Trim()); + return processName.Length > 0 && + !AutomaticCaptureRejectedProcesses.Contains(processName); + } + + internal static bool ShouldUseAutomaticGameCapture( + string hookedExecutable, + string foregroundExecutable, + bool hasVideo) => + hasVideo && + IsAutomaticCaptureCandidate(hookedExecutable) && + string.Equals( + Path.GetFileNameWithoutExtension(hookedExecutable), + Path.GetFileNameWithoutExtension(foregroundExecutable), + StringComparison.OrdinalIgnoreCase); + public bool IsHealthy { get @@ -173,26 +208,85 @@ public bool RefreshCaptureState() if (_desktopVideoSource == 0) return false; - if (hooked == _automaticGameActive) + bool processCandidate = hooked && + IsAutomaticCaptureCandidate(executable); + bool hasVideo = hooked && + ObsNative.obs_source_get_width(_gameVideoSource) > 0 && + ObsNative.obs_source_get_height(_gameVideoSource) > 0; + string foregroundExecutable = CaptureInterop.ForegroundExecutable(); + bool candidate = ShouldUseAutomaticGameCapture( + executable, + foregroundExecutable, + hasVideo); + if (!candidate) + { + ResetPendingAutomaticHook(); + if (!hooked) + _lastRejectedAutomaticExecutable = ""; + if (hooked && !processCandidate && + !string.Equals( + _lastRejectedAutomaticExecutable, + executable, + StringComparison.OrdinalIgnoreCase)) + { + _lastRejectedAutomaticExecutable = executable; + Log.Write( + $"Automatic Game Capture ignored non-game process: " + + $"{Path.GetFileName(executable)}"); + } + if (_automaticGameActive) + return SwitchAutomaticCapture(useGame: false, executable: ""); + return false; + } + + _lastRejectedAutomaticExecutable = ""; + if (_automaticGameActive) { - if (hooked && !string.IsNullOrWhiteSpace(executable)) - _activeGameExecutable = executable; + _activeGameExecutable = executable; return false; } - nint target = hooked ? _gameVideoSource : _desktopVideoSource; + if (!string.Equals( + _pendingAutomaticGameExecutable, + executable, + StringComparison.OrdinalIgnoreCase)) + { + _pendingAutomaticGameExecutable = executable; + _automaticHookStableChecks = 1; + return false; + } + _automaticHookStableChecks++; + if (_automaticHookStableChecks < AutomaticHookStableChecks) + return false; + + ResetPendingAutomaticHook(); + return SwitchAutomaticCapture(useGame: true, executable); + } + + private bool SwitchAutomaticCapture(bool useGame, string executable) + { + if (useGame == _automaticGameActive) + return false; + + nint target = useGame ? _gameVideoSource : _desktopVideoSource; ObsNative.obs_set_output_source(0, target); _videoSource = target; - _automaticGameActive = hooked; - _activeGameExecutable = hooked ? executable : ""; + _automaticGameActive = useGame; + _activeGameExecutable = useGame ? executable : ""; Log.Write( - hooked + useGame ? $"Automatic capture switched to Game Capture: " + $"{Path.GetFileName(_activeGameExecutable)}" : "Automatic capture returned to Desktop Capture."); return true; } + private void ResetPendingAutomaticHook() + { + _pendingAutomaticGameExecutable = ""; + _automaticHookStableChecks = 0; + } + public ObsReplayEngine(Config config) { _config = config; @@ -427,6 +521,13 @@ private void InitializeObs() ObsNative.obs_add_data_path( ToObsPath(Path.Combine(dataRoot, "libobs")) + "/"); + // OBS injects graphics-hook*.dll into captured games. Windows can keep + // that image mapped until the game exits, even after OBS shuts down. + // Serve plugin data from a user cache so games never lock Captail's + // installation or portable directory. + string pluginDataRoot = ObsPluginDataCache.Prepare(dataRoot); + Log.Write($"OBS plugin data cache ready: {pluginDataRoot}"); + List monitors = CaptureInterop.EnumerateMonitors(); CaptureInterop.MonitorInfo monitor = _config.MonitorIndex >= 0 && _config.MonitorIndex < monitors.Count @@ -492,7 +593,7 @@ private void InitializeObs() ObsNative.obs_add_module_path( ToObsPath(Path.Combine(baseDirectory, "obs-plugins", "64bit")), - ToObsPath(Path.Combine(dataRoot, "obs-plugins", "%module%"))); + ToObsPath(Path.Combine(pluginDataRoot, "obs-plugins", "%module%"))); ObsNative.obs_load_all_modules(); ObsNative.obs_post_load_modules(); } diff --git a/src/Captail/ReplayLibrary.cs b/src/Captail/ReplayLibrary.cs index ca97fff..8ae2a7f 100644 --- a/src/Captail/ReplayLibrary.cs +++ b/src/Captail/ReplayLibrary.cs @@ -51,7 +51,9 @@ public async Task> GetRecentAsync( AttributesToSkip = FileAttributes.ReparsePoint, }; files = Directory.EnumerateFiles(root, "*", options) - .Where(path => VideoExtensions.Contains(Path.GetExtension(path))) + .Where(path => + VideoExtensions.Contains(Path.GetExtension(path)) && + !IsInternalWorkingFile(path)) .Select(path => new FileInfo(path)) .Where(file => file.Exists && file.Length > 0) .OrderByDescending(file => file.LastWriteTimeUtc) @@ -336,6 +338,15 @@ private static string NormalizeRoot(string rootDirectory) => Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + internal static bool IsInternalWorkingFile(string path) + { + string name = Path.GetFileName(path); + return name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) || + name.Contains(".tmp.", StringComparison.OrdinalIgnoreCase) || + (name.StartsWith('.') && + name.Contains(".replacement", StringComparison.OrdinalIgnoreCase)); + } + private static string UniquePath(string directory, string baseName, string extension) { string candidate = Path.Combine(directory, baseName + extension); diff --git a/src/Captail/SettingsWindow.xaml b/src/Captail/SettingsWindow.xaml index f83b170..21b8db7 100644 --- a/src/Captail/SettingsWindow.xaml +++ b/src/Captail/SettingsWindow.xaml @@ -249,16 +249,31 @@ + + + + + + PanningMode="VerticalOnly" Padding="0,0,6,0" + Visibility="Collapsed"> diff --git a/src/Captail/SettingsWindow.xaml.cs b/src/Captail/SettingsWindow.xaml.cs index 9cbfa98..83212d9 100644 --- a/src/Captail/SettingsWindow.xaml.cs +++ b/src/Captail/SettingsWindow.xaml.cs @@ -1264,6 +1264,7 @@ private async Task RefreshReplayLibraryAsync() { if (Interlocked.Exchange(ref _libraryRefreshInProgress, 1) != 0) return; + SetReplayLibraryState(loading: true); try { IReadOnlyList clips = await _replayLibrary.GetRecentAsync( @@ -1274,9 +1275,7 @@ private async Task RefreshReplayLibraryAsync() return; RecentReplaysList.ItemsSource = clips.Select(CreateReplayClipItem).ToArray(); - ReplayLibraryEmptyText.Visibility = clips.Count == 0 - ? Visibility.Visible - : Visibility.Collapsed; + SetReplayLibraryState(empty: clips.Count == 0); } catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) { @@ -1286,7 +1285,7 @@ private async Task RefreshReplayLibraryAsync() { Log.Write($"Replay library refresh failed: {exception}"); RecentReplaysList.ItemsSource = null; - ReplayLibraryEmptyText.Visibility = Visibility.Visible; + SetReplayLibraryState(error: true); } finally { @@ -1294,6 +1293,25 @@ private async Task RefreshReplayLibraryAsync() } } + private void SetReplayLibraryState( + bool loading = false, + bool empty = false, + bool error = false) + { + ReplayLibraryLoading.Visibility = loading + ? Visibility.Visible + : Visibility.Collapsed; + ReplayLibraryEmptyText.Visibility = empty + ? Visibility.Visible + : Visibility.Collapsed; + ReplayLibraryErrorText.Visibility = error + ? Visibility.Visible + : Visibility.Collapsed; + RecentReplaysScrollViewer.Visibility = !loading && !empty && !error + ? Visibility.Visible + : Visibility.Collapsed; + } + private ReplayClipItem CreateReplayClipItem(ReplayClip clip) { BitmapImage? thumbnail = null; diff --git a/src/Captail/Themes/Theme.xaml b/src/Captail/Themes/Theme.xaml index 0a8a5ae..8af0714 100644 --- a/src/Captail/Themes/Theme.xaml +++ b/src/Captail/Themes/Theme.xaml @@ -84,6 +84,8 @@ M3 6h18 M8 6V3h8v3 M19 6l-1 15H6L5 6 M10 11v6 M14 11v6 M8 5v14l11-7z M8 5h3v14H8z M14 5h3v14h-3z + M8 3H3v5 M16 3h5v5 M8 21H3v-5 M16 21h5v-5 + M3 8h5V3 M21 8h-5V3 M3 16h5v5 M21 16h-5v5 M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.69c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.35 1.09 2.92.83.09-.65.35-1.09.64-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.58 9.58 0 0 1 12 7.7a9.6 9.6 0 0 1 2.5.34c1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.86V21c0 .27.18.58.69.48A10 10 0 0 0 12 2z @@ -111,6 +113,43 @@ + + + +