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 @@
+
@@ -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 @@
CAPTAIL
-
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.
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 @@
+
+
+
+