diff --git a/CHANGELOG.md b/CHANGELOG.md
index b763999..2925937 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable user-facing changes are documented here.
## [Unreleased]
+## [0.1.3] - 2026-07-26
+
+### Added
+
+- Concise English and Russian help tooltips for replay, video, and audio settings.
+- Dashboard footer with repository access, current version, and one-click updates for installed and Portable builds.
+
+### Fixed
+
+- Watchdog checks no longer mistake an in-progress pipeline startup for a stopped recording module.
+- Consecutive replay saves now advance the rolling window so later clips contain only footage recorded since the previous save.
+- Save controls and notifications now show the currently available replay duration instead of always showing the configured maximum.
+
## [0.1.2] - 2026-07-22
### Fixed
diff --git a/README.md b/README.md
index 281653c..c29fd78 100644
--- a/README.md
+++ b/README.md
@@ -13,13 +13,28 @@
Captail is designed around one rule: **Instant Replay should stay on.** A game crash, game-to-desktop switch, temporary capture failure, protected surface, or recoverable graphics-driver interruption should not silently leave you without a replay. A watchdog monitors the recording pipeline and restarts it when recovery is possible.
+## Interface
+
+
+
+
+
+
> [!WARNING]
> Captail `v0.1.x` is an early public preview. Core recording works, but bugs and hardware-specific problems are expected. Please report anything that does not work.
+## What's new in v0.1.3
+
+- **Built-in updates** — the footer shows update status and installs the correct Installer or Portable package after verifying its SHA-256 digest.
+- **Clearer settings** — concise help popups explain codecs, bitrate, resolution, frame rate, audio tracks, buffer limits, and other technical options in English and Russian.
+- **Non-overlapping saves** — after saving a replay, the next clip starts at that save boundary instead of repeating footage from the previous file.
+- **More accurate save duration** — the main action shows how much new footage is currently available.
+- **Safer recovery** — the watchdog no longer treats normal recording-pipeline startup as a failure.
+
## Why Captail?
- **Small, focused UI** — replay state, active source, audio, hotkey, and disk space at a glance.
@@ -53,6 +68,8 @@ Download the latest version from [GitHub Releases](https://github.com/FaulMit/ca
Both packages are self-contained. **OBS Studio and .NET do not need to be installed separately.**
+Captail checks GitHub Releases in the background. When an update is available, the version in the footer becomes a mint update button. Click it to download, verify, install, and restart. Installer builds update through Setup; Portable builds replace their extracted files without changing package type.
+
> [!NOTE]
> Current binaries are not Authenticode-signed. Windows SmartScreen may show an “Unknown publisher” warning. Verify the SHA-256 checksum and GitHub build provenance before running a release.
@@ -104,6 +121,8 @@ Both hotkeys can be changed in Settings.
- Start with Windows.
- Native overlay notifications for replay state and saved clips.
- Single-instance protection.
+- Built-in GitHub release indicator and verified one-click updates.
+- Direct repository link in the footer.
- English interface by default, with live English/Russian switching.
### Reliability
@@ -111,6 +130,7 @@ Both hotkeys can be changed in Settings.
- Recording-pipeline watchdog.
- Automatic replay restart after recoverable capture or encoder failure.
- Progressive retry delay when the graphics driver is temporarily unavailable.
+- Sequential save boundaries prevent duplicate footage across consecutive clips.
- Fragmented MP4 output to reduce the risk of losing an entire file after interruption.
- Protected/DRM surfaces may become black while the rolling buffer continues.
@@ -144,7 +164,7 @@ Feedback is especially valuable during this preview.
1. Check [existing issues](https://github.com/FaulMit/captail/issues).
2. Open a [bug report](https://github.com/FaulMit/captail/issues/new/choose).
3. Include Captail version, Windows version, GPU, driver version, source, codec, FPS, resolution, and reproduction steps.
-4. Attach `%APPDATA%\Captail\log.txt` when possible. Review it first and remove personal paths or other sensitive information.
+4. Attach `%LOCALAPPDATA%\Captail\log.txt` when possible. Review it first and remove personal paths or other sensitive information.
Requests for older NVIDIA, AMD, and Intel testing are welcome even when everything works.
@@ -180,6 +200,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines and [docs/RELE
- `native/ObsCaptureFixture` — D3D11 fixture for Game Capture and high-FPS validation.
- `App.xaml.cs` — tray, hotkeys, notifications, single-instance behavior, watchdog, and recovery.
- `SettingsWindow.*` — compact WPF interface.
+- `UpdateService.cs` — GitHub release discovery, package verification, Installer updates, and Portable self-replacement.
## License and attribution
diff --git a/docs/captail-main.jpg b/docs/captail-main.jpg
index c43d7d8..237557f 100644
Binary files a/docs/captail-main.jpg and b/docs/captail-main.jpg differ
diff --git a/docs/captail-settings-audio.jpg b/docs/captail-settings-audio.jpg
new file mode 100644
index 0000000..1a65459
Binary files /dev/null and b/docs/captail-settings-audio.jpg differ
diff --git a/docs/captail-settings-video.jpg b/docs/captail-settings-video.jpg
new file mode 100644
index 0000000..1e207ab
Binary files /dev/null and b/docs/captail-settings-video.jpg differ
diff --git a/src/Captail/App.xaml.cs b/src/Captail/App.xaml.cs
index dc6e8ef..fd943cf 100644
--- a/src/Captail/App.xaml.cs
+++ b/src/Captail/App.xaml.cs
@@ -36,6 +36,8 @@ public partial class App : Application
private int _recoveryFailures;
private int _recoveryInProgress;
private OverlayNotificationWindow? _overlayNotification;
+ private readonly UpdateService _updateService = new();
+ private DispatcherTimer? _updateShutdownTimer;
private int _saving;
private EncoderCapabilities? _capabilities;
private readonly SemaphoreSlim _pipelineGate = new(1, 1);
@@ -45,6 +47,9 @@ public partial class App : Application
private string? _captureDescription;
private int _exiting;
private bool _shutdownExistingSucceeded = true;
+#if DEBUG
+ private bool _qaUpdateAvailable;
+#endif
private bool IsReplayRunning => _replayRunning;
@@ -69,11 +74,22 @@ protected override async void OnStartup(StartupEventArgs e)
argument => argument.StartsWith(
"--qa-game-capture=",
StringComparison.OrdinalIgnoreCase));
+ bool replaySegmentsTest = e.Args.Contains(
+ "--qa-replay-segments",
+ StringComparer.OrdinalIgnoreCase);
+ bool updateCheckTest = e.Args.Contains(
+ "--qa-update-check",
+ StringComparer.OrdinalIgnoreCase);
+ _qaUpdateAvailable = e.Args.Contains(
+ "--qa-update-available",
+ StringComparer.OrdinalIgnoreCase);
#else
const bool faultTest = false;
const bool codecTest = false;
const bool capabilityModelTest = false;
const bool gameCaptureTest = false;
+ const bool replaySegmentsTest = false;
+ const bool updateCheckTest = false;
#endif
bool backgroundLaunch = e.Args.Contains(
"--background",
@@ -85,7 +101,7 @@ protected override async void OnStartup(StartupEventArgs e)
backgroundLaunch,
shutdownExisting,
_uiOnly || faultTest || codecTest || capabilityModelTest ||
- gameCaptureTest))
+ gameCaptureTest || replaySegmentsTest || updateCheckTest))
{
Shutdown();
return;
@@ -134,6 +150,19 @@ protected override async void OnStartup(StartupEventArgs e)
RunGameCaptureTest(e.Args);
return;
}
+ if (replaySegmentsTest)
+ {
+ RunReplaySegmentsTest();
+ return;
+ }
+ if (updateCheckTest)
+ {
+ await _updateService.CheckAsync(
+ force: true,
+ CancellationToken.None);
+ Shutdown(0);
+ return;
+ }
#endif
if (_uiOnly)
{
@@ -917,34 +946,42 @@ private async Task MonitorPipelineAsync()
return;
}
- if (_obs is null)
- {
- await RecoverPipelineAsync(Localization.Text("L.Recovery.ModuleStopped"));
- return;
- }
- if (DateTime.UtcNow - _pipelineStartedUtc < TimeSpan.FromSeconds(8))
- return;
if (!await _pipelineGate.WaitAsync(0))
return;
- bool healthy;
+ string? recoveryReason = null;
try
{
ObsReplayEngine? engine = _obs;
- (healthy, string? description) = engine is null
- ? (false, null)
- : await RunOnObsThreadAsync(() =>
- (engine.IsHealthy, engine.Description));
- if (healthy)
- _captureDescription = description;
+ if (engine is null)
+ {
+ recoveryReason = Localization.Text(
+ "L.Recovery.ModuleStopped");
+ }
+ else if (DateTime.UtcNow - _pipelineStartedUtc <
+ TimeSpan.FromSeconds(8))
+ {
+ return;
+ }
+ else
+ {
+ (bool healthy, string? description) =
+ await RunOnObsThreadAsync(() =>
+ (engine.IsHealthy, engine.Description));
+ if (healthy)
+ _captureDescription = description;
+ else
+ recoveryReason = Localization.Text(
+ "L.Recovery.NoFrames");
+ }
}
finally
{
_pipelineGate.Release();
}
- if (!healthy)
- await RecoverPipelineAsync(Localization.Text("L.Recovery.NoFrames"));
+ if (recoveryReason is not null)
+ await RecoverPipelineAsync(recoveryReason);
else
UpdateUiState();
}
@@ -1145,7 +1182,9 @@ private void OpenSettings()
SetReplayEnabledAsync,
SetAudioSourcesAsync,
ApplySettingsAsync,
- capabilities);
+ capabilities,
+ CheckForUpdatesAsync,
+ PrepareAndLaunchUpdateAsync);
_settingsWindow.Closed += (_, _) =>
{
_settingsWindow = null;
@@ -1169,6 +1208,58 @@ private void OpenSettings()
}
}
+ private Task CheckForUpdatesAsync(
+ bool force,
+ CancellationToken cancellationToken)
+ {
+#if DEBUG
+ if (_qaUpdateAvailable)
+ {
+ var asset = new UpdateAsset(
+ "qa",
+ new Uri("https://github.com/FaulMit/captail"),
+ 1,
+ $"sha256:{new string('0', 64)}");
+ return Task.FromResult(
+ new UpdateRelease(
+ new Version(0, 2, 0),
+ "v0.2.0",
+ new Uri(
+ "https://github.com/FaulMit/captail/releases/tag/v0.2.0"),
+ true,
+ asset,
+ asset,
+ null));
+ }
+#endif
+ return _updateService.CheckAsync(force, cancellationToken);
+ }
+
+ private async Task PrepareAndLaunchUpdateAsync(
+ UpdateRelease release,
+ IProgress progress,
+ CancellationToken cancellationToken)
+ {
+ PreparedUpdate update = await _updateService.PrepareAsync(
+ release,
+ progress,
+ cancellationToken);
+ UpdateService.Launch(update);
+
+ _updateShutdownTimer?.Stop();
+ _updateShutdownTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(450),
+ };
+ _updateShutdownTimer.Tick += (_, _) =>
+ {
+ _updateShutdownTimer?.Stop();
+ _updateShutdownTimer = null;
+ _ = RequestShutdownAsync();
+ };
+ _updateShutdownTimer.Start();
+ }
+
private async Task EnsureCapabilitiesAsync()
{
if (_capabilities is not null &&
@@ -1494,12 +1585,16 @@ private void UpdateUiState()
{
bool active = IsReplayRunning;
string codec = _obs?.ActiveCodec ?? _config?.Codec ?? "h264";
+ int availableReplaySeconds = active
+ ? _obs?.AvailableReplaySeconds ?? _config!.BufferSeconds
+ : 0;
if (_capabilities is not null)
_settingsWindow?.UpdateCapabilities(_capabilities);
_settingsWindow?.UpdateRuntimeState(
active,
codec,
- _captureDescription);
+ _captureDescription,
+ availableReplaySeconds);
if (_tray is not null)
{
_tray.ToolTipText = active
@@ -1537,6 +1632,11 @@ private async Task SaveReplayCoreAsync(ObsReplayEngine engine)
{
try
{
+ int availableReplaySeconds = Math.Max(
+ 1,
+ Math.Min(
+ _config!.BufferSeconds,
+ engine.AvailableReplaySeconds));
// Shown immediately so the user sees progress; replaced by the result
// notification once the file is on disk. The long duration is a safety
// net — the "saved"/"failed" notification supersedes it well before then.
@@ -1545,7 +1645,7 @@ private async Task SaveReplayCoreAsync(ObsReplayEngine engine)
Localization.Text("L.Notify.Saving"),
Localization.Format(
"L.Notify.SavingDetail",
- FormatDuration(_config!.BufferSeconds)),
+ FormatDuration(availableReplaySeconds)),
OverlayTone.Neutral,
30_000);
string path = await SaveReplayGuardedAsync(engine);
@@ -1581,10 +1681,29 @@ private async Task SaveReplayGuardedAsync(ObsReplayEngine engine)
throw new InvalidOperationException(
Localization.Text("L.Notify.EnableBeforeSave"));
}
- Task saveOperation = await RunOnObsThreadAsync(
- () => engine.SaveReplayAsync())
+ ReplaySaveOperation operation = await RunOnObsThreadAsync(
+ () => engine.BeginSaveReplay())
+ .ConfigureAwait(false);
+ bool snapshotStarted = await WaitForSaveSnapshotAsync(
+ engine,
+ operation)
.ConfigureAwait(false);
- return await saveOperation.ConfigureAwait(false);
+ if (snapshotStarted)
+ {
+ await RunOnObsThreadAsync(engine.ResetReplayWindow)
+ .ConfigureAwait(false);
+ }
+
+ string path = await operation.Completion.ConfigureAwait(false);
+ if (!snapshotStarted)
+ {
+ Log.Write(
+ "Replay snapshot marker was delayed; advancing window " +
+ "after mux completion.");
+ await RunOnObsThreadAsync(engine.ResetReplayWindow)
+ .ConfigureAwait(false);
+ }
+ return path;
}
finally
{
@@ -1592,6 +1711,91 @@ private async Task SaveReplayGuardedAsync(ObsReplayEngine engine)
}
}
+#if DEBUG
+ private async void RunReplaySegmentsTest()
+ {
+ try
+ {
+ string root = Path.Combine(
+ Path.GetTempPath(),
+ "Captail",
+ $"obs_segments_{Environment.ProcessId}");
+ _config = new Config
+ {
+ ReplayEnabled = true,
+ BufferSeconds = 15,
+ FrameRate = 30,
+ BitrateMbps = 8,
+ Codec = "h264",
+ CaptureSource = "desktop",
+ CaptureSystemAudio = false,
+ CaptureMicrophone = false,
+ OutputDirectory = root,
+ };
+ if (!await TryStartPipelineAsync(showError: false))
+ {
+ throw new InvalidOperationException(
+ "The replay segment pipeline did not start.");
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(6));
+ string first = await SaveReplayGuardedAsync(_obs!);
+ int availableAfterFirst = _obs!.AvailableReplaySeconds;
+ await Task.Delay(TimeSpan.FromSeconds(3));
+ string second = await SaveReplayGuardedAsync(_obs);
+ int availableAfterSecond = _obs.AvailableReplaySeconds;
+
+ bool passed =
+ File.Exists(first) &&
+ File.Exists(second) &&
+ new FileInfo(first).Length > 0 &&
+ new FileInfo(second).Length > 0 &&
+ availableAfterFirst <= 1 &&
+ availableAfterSecond <= 1;
+ Log.Write(
+ $"OBS_SEGMENT_TEST {(passed ? "PASS" : "FAIL")}: " +
+ $"first={first}, second={second}, " +
+ $"availableAfterFirst={availableAfterFirst}s, " +
+ $"availableAfterSecond={availableAfterSecond}s");
+ await StopPipelineCoreAsync();
+ Shutdown(passed ? 0 : 13);
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"OBS_SEGMENT_TEST FAIL: {exception}");
+ Shutdown(13);
+ }
+ }
+#endif
+
+ private async Task WaitForSaveSnapshotAsync(
+ ObsReplayEngine engine,
+ ReplaySaveOperation operation)
+ {
+ DateTime deadline = DateTime.UtcNow.AddSeconds(5);
+ while (DateTime.UtcNow < deadline)
+ {
+ if (!ReferenceEquals(engine, _obs) ||
+ !IsReplayRunning ||
+ Volatile.Read(ref _exiting) != 0)
+ {
+ throw new InvalidOperationException(
+ Localization.Text("L.Notify.EnableBeforeSave"));
+ }
+
+ bool started = await RunOnObsThreadAsync(
+ () => engine.HasSaveSnapshotStarted(operation))
+ .ConfigureAwait(false);
+ if (started)
+ return true;
+ if (operation.Completion.IsCompleted)
+ break;
+ await Task.Delay(15).ConfigureAwait(false);
+ }
+
+ return false;
+ }
+
private void OnLanguageChanged()
{
if (!Dispatcher.CheckAccess())
@@ -1668,6 +1872,7 @@ protected override void OnExit(ExitEventArgs e)
Interlocked.Exchange(ref _exiting, 1) != 0 && _obs is null;
Localization.Changed -= OnLanguageChanged;
_healthTimer?.Stop();
+ _updateShutdownTimer?.Stop();
_activationServerCts?.Cancel();
_activationServerCts?.Dispose();
_settingsWindow?.Close();
diff --git a/src/Captail/Captail.csproj b/src/Captail/Captail.csproj
index e06d31a..03ef977 100644
--- a/src/Captail/Captail.csproj
+++ b/src/Captail/Captail.csproj
@@ -14,7 +14,7 @@
CaptailCaptailAssets\Captail.ico
- 0.1.2
+ 0.1.3true
diff --git a/src/Captail/Languages/Strings.en.xaml b/src/Captail/Languages/Strings.en.xaml
index 0b7a40f..db77f63 100644
--- a/src/Captail/Languages/Strings.en.xaml
+++ b/src/Captail/Languages/Strings.en.xaml
@@ -10,6 +10,20 @@
DoneMinimizeClose
+ Open Captail on GitHub
+ Version {0} · click to check for updates
+ Checking…
+ Checking GitHub Releases
+ Captail {0} is up to date · click to check again
+ Update v{0}
+ Version {0} is ready · click to download and update
+ Downloading {0}%
+ Downloading and verifying Captail {0}
+ Installing…
+ Captail will restart after the update
+ Could not check for updates · click to retry
+ Update check failed
+ Update failedInstant Replay is onInstant Replay is off
@@ -59,6 +73,8 @@
Maximum compressed video and audio packet memoryLimits RAM use and durationDuration only
+ How much recent footage Captail keeps ready. Longer buffers use more RAM. After a save, the next clip starts with new footage instead of repeating the previous clip.
+ Hard limit for compressed replay data in RAM. If this limit is reached first, the available replay will be shorter. Leave “Duration only” unless you need predictable memory use.250 MB500 MB1 GB
@@ -90,6 +106,13 @@
source{0} · not runningChoose a running game…
+ Desktop captures the selected monitor; protected video may appear black without stopping the buffer. Game Capture hooks the selected game directly for cleaner high-FPS recording.
+ AV1 — newest and most efficient. Cleaner at the same bitrate, or smaller files at the same quality. Hardware support includes GeForce RTX 40/50, Radeon RX 7000 and Intel Arc.
+ HEVC — nearly as compact and supported by more modern GPUs.
+ H.264 — uses more space, but opens almost everywhere.
+ How much video data is written each second. Higher values keep fast motion cleaner, but use more RAM and disk space. 50 Mbps is roughly 375 MB per minute. Auto adapts to codec, resolution and FPS.
+ Source keeps the original dimensions. Lower resolution reduces GPU load, RAM use and file size, but also removes detail. It does not change monitor resolution.
+ 60 FPS is enough for normal playback. 120–240 FPS records real extra frames for smooth slow motion; Captail does not invent frames. The game must render fast enough, and higher FPS increases GPU and memory use.System audioGame audio
@@ -102,6 +125,9 @@
One mixed trackSeparate tracksAudio codec
+ Raises microphone gain before encoding. Use it when 100% volume is still quiet. Too much boost also raises noise and can make loud speech distort.
+ Mixed track plays everywhere and is easiest to share. Separate tracks keep game/system audio and microphone independent for editing; some players play only one track at a time.
+ AAC saves as MP4 and works with almost every player and editor. Opus is efficient and sounds good at lower bitrates, but uses MKV and may need a modern editor.Replay folderChange…
diff --git a/src/Captail/Languages/Strings.ru.xaml b/src/Captail/Languages/Strings.ru.xaml
index 4ab406d..82bb287 100644
--- a/src/Captail/Languages/Strings.ru.xaml
+++ b/src/Captail/Languages/Strings.ru.xaml
@@ -10,6 +10,20 @@
ГотовоСвернутьЗакрыть
+ Открыть Captail на GitHub
+ Версия {0} · нажмите, чтобы проверить обновления
+ Проверка…
+ Проверяем GitHub Releases
+ Установлена актуальная версия {0} · нажмите для повторной проверки
+ Обновить до v{0}
+ Доступна версия {0} · нажмите, чтобы скачать и обновить
+ Загрузка {0}%
+ Скачиваем и проверяем Captail {0}
+ Установка…
+ После обновления Captail перезапустится
+ Не удалось проверить обновления · нажмите, чтобы повторить
+ Ошибка проверки обновлений
+ Ошибка обновленияМгновенный повтор включёнМгновенный повтор выключен
@@ -59,6 +73,8 @@
Максимальный объём сжатых видео- и аудиопакетов в оперативной памятиОграничивает ОЗУ и длительностьТолько длительность
+ Сколько последних секунд Captail держит наготове. Чем длиннее буфер, тем больше нужно ОЗУ. После сохранения следующий клип начинается с новых кадров и не повторяет предыдущий.
+ Жёсткий лимит сжатого буфера в ОЗУ. Если память закончится раньше времени, доступный повтор станет короче. Оставьте «Только длительность», если точный расход памяти не важен.250 МБ500 МБ1 ГБ
@@ -90,6 +106,13 @@
исходное{0} · не запущенаВыберите запущенную игру…
+ «Рабочий стол» записывает выбранный монитор; защищённое видео может быть чёрным, но буфер продолжит работу. Game Capture напрямую захватывает выбранную игру и лучше подходит для высокого FPS.
+ AV1 — самый современный и эффективный. Чище при том же битрейте или меньше файл при том же качестве. Поддержка есть на GeForce RTX 40/50, Radeon RX 7000 и Intel Arc.
+ HEVC — почти такой же компактный и поддерживается большим числом современных GPU.
+ H.264 — занимает больше места, зато открывается почти везде.
+ Сколько видеоданных записывается каждую секунду. Чем выше значение, тем чище быстрое движение, но больше расход ОЗУ и диска. 50 Мбит/с — примерно 375 МБ в минуту. «Авто» учитывает кодек, разрешение и FPS.
+ «Как у источника» сохраняет исходный размер кадра. Меньшее разрешение снижает нагрузку, расход ОЗУ и размер файла, но убирает детали. Разрешение монитора не меняется.
+ 60 FPS достаточно для обычного просмотра. 120–240 FPS записывают реальные дополнительные кадры для плавного замедления — Captail не дорисовывает фейковые. Игра должна выдавать нужный FPS; нагрузка и расход памяти растут.Звук рабочего столаЗвук игры
@@ -102,6 +125,9 @@
Одна общаяРаздельныеАудиокодек
+ Усиливает микрофон до кодирования. Используйте, если даже на 100% голос тихий. Слишком высокое усиление также поднимает шум и может перегружать громкую речь.
+ Общая дорожка работает почти везде и удобна для просмотра. Раздельные сохраняют игру/систему и микрофон отдельно для монтажа; некоторые плееры воспроизводят только одну дорожку за раз.
+ AAC сохраняется в MP4 и работает почти со всеми плеерами и редакторами. Opus эффективнее на низком битрейте, но использует MKV и может потребовать современный редактор.Папка повторовИзменить…
diff --git a/src/Captail/ObsReplayEngine.cs b/src/Captail/ObsReplayEngine.cs
index 83efade..21eb791 100644
--- a/src/Captail/ObsReplayEngine.cs
+++ b/src/Captail/ObsReplayEngine.cs
@@ -38,8 +38,10 @@ public sealed class ObsReplayEngine : IDisposable
private bool _obsStarted;
private bool _logBridgeInstalled;
private bool _disposing;
+ private bool _resettingReplayWindow;
private uint _previousFrameCount;
private DateTime _previousFrameCheckUtc;
+ private DateTime _replayWindowStartedUtc;
private uint _outputWidth;
private uint _outputHeight;
private uint _baseWidth;
@@ -108,6 +110,18 @@ public bool IsHealthy
public ulong BufferedBytes =>
_output == 0 ? 0 : ObsNative.obs_output_get_total_bytes(_output);
+ public int AvailableReplaySeconds
+ {
+ get
+ {
+ if (!IsActive || _replayWindowStartedUtc == default)
+ return 0;
+
+ int elapsed = (int)Math.Floor(
+ (DateTime.UtcNow - _replayWindowStartedUtc).TotalSeconds);
+ return Math.Clamp(elapsed, 0, _config.BufferSeconds);
+ }
+ }
public uint TotalRenderedFrames => ObsNative.obs_get_total_frames();
public uint LaggedRenderedFrames => ObsNative.obs_get_lagged_frames();
@@ -182,21 +196,23 @@ public static EncoderCapabilities ProbeCapabilities(Config config)
}
using (probe)
- try
- {
- probe.InitializeObs();
- return probe.DetectCapabilities();
- }
- catch (Exception exception)
- {
- Log.Write($"GPU capability detection failed: {exception}");
- return EncoderCapabilities.Failed(exception.Message);
- }
+ try
+ {
+ probe.InitializeObs();
+ return probe.DetectCapabilities();
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"GPU capability detection failed: {exception}");
+ return EncoderCapabilities.Failed(exception.Message);
+ }
}
- public async Task SaveReplayAsync(CancellationToken cancellationToken = default)
+ public ReplaySaveOperation BeginSaveReplay(
+ CancellationToken cancellationToken = default)
{
Task completion;
+ ulong initialMuxBytes;
lock (_saveGate)
{
if (!IsActive)
@@ -209,6 +225,7 @@ public async Task SaveReplayAsync(CancellationToken cancellationToken =
_pendingSave = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
completion = _pendingSave.Task;
+ initialMuxBytes = BufferedBytes;
nint procedures = ObsNative.obs_output_get_proc_handler(_output);
if (procedures == 0 ||
!ObsNative.proc_handler_call(procedures, "save", 0))
@@ -219,6 +236,68 @@ public async Task SaveReplayAsync(CancellationToken cancellationToken =
}
}
+ return new ReplaySaveOperation(
+ WaitForSaveCompletionAsync(completion, cancellationToken),
+ initialMuxBytes);
+ }
+
+ public Task SaveReplayAsync(
+ CancellationToken cancellationToken = default) =>
+ BeginSaveReplay(cancellationToken).Completion;
+
+ public bool HasSaveSnapshotStarted(ReplaySaveOperation operation) =>
+ BufferedBytes != operation.InitialMuxBytes;
+
+ public void ResetReplayWindow()
+ {
+ if (!IsActive)
+ throw new InvalidOperationException(
+ Localization.Text("L.Engine.BufferStopped"));
+
+ _resettingReplayWindow = true;
+ try
+ {
+ ObsNative.obs_output_stop(_output);
+ for (int attempt = 0;
+ attempt < 80 && ObsNative.obs_output_active(_output);
+ attempt++)
+ {
+ Thread.Sleep(25);
+ }
+ if (ObsNative.obs_output_active(_output))
+ {
+ ObsNative.obs_output_force_stop(_output);
+ for (int attempt = 0;
+ attempt < 40 && ObsNative.obs_output_active(_output);
+ attempt++)
+ {
+ Thread.Sleep(25);
+ }
+ }
+ if (ObsNative.obs_output_active(_output) ||
+ !ObsNative.obs_output_start(_output))
+ {
+ string error = PtrToString(
+ ObsNative.obs_output_get_last_error(_output));
+ throw new InvalidOperationException(
+ string.IsNullOrWhiteSpace(error)
+ ? Localization.Text("L.Engine.BufferStartFailed")
+ : error);
+ }
+
+ _replayWindowStartedUtc = DateTime.UtcNow;
+ Log.Write("Replay window advanced after save.");
+ }
+ finally
+ {
+ _resettingReplayWindow = false;
+ }
+ }
+
+ private async Task WaitForSaveCompletionAsync(
+ Task completion,
+ CancellationToken cancellationToken)
+ {
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(45));
try
@@ -920,6 +999,7 @@ private void CreateReplayBuffer()
? Localization.Text("L.Engine.BufferStartFailed")
: error);
}
+ _replayWindowStartedUtc = DateTime.UtcNow;
}
private int AudioTrackCount()
@@ -952,7 +1032,7 @@ private void OnReplaySaved(nint _, nint __)
private void OnOutputStopped(nint _, nint __)
{
- if (_disposing)
+ if (_disposing || _resettingReplayWindow)
return;
string error = PtrToString(ObsNative.obs_output_get_last_error(_output));
Faulted?.Invoke(
@@ -1131,3 +1211,7 @@ public void Dispose()
_contextOwned = false;
}
}
+
+public sealed record ReplaySaveOperation(
+ Task Completion,
+ ulong InitialMuxBytes);
diff --git a/src/Captail/SettingsWindow.xaml b/src/Captail/SettingsWindow.xaml
index 6b8c16a..a3d424f 100644
--- a/src/Captail/SettingsWindow.xaml
+++ b/src/Captail/SettingsWindow.xaml
@@ -24,6 +24,7 @@
+
-
+
+
+
+
@@ -262,9 +275,20 @@
-
+
+
+
+
@@ -297,7 +321,19 @@
-
+
+
+
+
@@ -328,7 +364,21 @@
-
+
+
+
+
-
+
+
+
+
@@ -375,7 +437,19 @@
-
+
+
+
+
@@ -387,7 +461,19 @@
-
+
+
+
+
@@ -450,13 +536,24 @@
-
+
-
+
+
+
+
-
+
+
+
+
@@ -489,7 +598,19 @@
-
+
+
+
+
@@ -565,7 +686,41 @@
-
+
+
+
+
+
+
+ > _setReplayEnabled;
private readonly Func> _setAudioSources;
private readonly Func> _applySettings;
+ private readonly Func>
+ _checkForUpdates;
+ private readonly Func<
+ UpdateRelease,
+ IProgress,
+ CancellationToken,
+ Task> _installUpdate;
private EncoderCapabilities _capabilities;
private readonly DispatcherTimer _diskTimer;
private readonly CancellationTokenSource _lifetimeCts = new();
@@ -28,22 +35,34 @@ public partial class SettingsWindow : Window
private Button? _capturingHotkeyButton;
private bool _updatingUi;
private bool _runtimeActive;
+ private int _availableReplaySeconds;
private bool? _animatedRecordingState;
private int _deviceRefreshVersion;
private int _processRefreshVersion;
private int _diskRefreshInProgress;
private int _actionInProgress;
+ private UpdateRelease? _availableUpdate;
+ private UpdateDisplayState _updateDisplayState = UpdateDisplayState.Current;
+ private int _updateProgress;
+ private bool _updateCheckInProgress;
+ private bool _updateInstallInProgress;
public bool Applied { get; private set; }
- public SettingsWindow(
+ internal SettingsWindow(
Config config,
bool runtimeActive,
Action saveReplay,
Func> setReplayEnabled,
Func> setAudioSources,
Func> applySettings,
- EncoderCapabilities capabilities)
+ EncoderCapabilities capabilities,
+ Func> checkForUpdates,
+ Func<
+ UpdateRelease,
+ IProgress,
+ CancellationToken,
+ Task> installUpdate)
{
_config = config;
_saveReplay = saveReplay;
@@ -51,6 +70,8 @@ public SettingsWindow(
_setAudioSources = setAudioSources;
_applySettings = applySettings;
_capabilities = capabilities;
+ _checkForUpdates = checkForUpdates;
+ _installUpdate = installUpdate;
_outputDirectory = config.OutputDirectory;
_pendingSaveHotkey = config.Hotkey;
_pendingToggleHotkey = config.ToggleReplayHotkey;
@@ -72,13 +93,18 @@ public SettingsWindow(
ResetDeviceLists();
LoadSettingsControls();
UpdateRuntimeState(runtimeActive);
- Loaded += async (_, _) => await RunUiActionAsync(async () =>
+ RenderUpdateStatus();
+ Loaded += async (_, _) =>
{
- await Task.WhenAll(
- LoadDeviceListsAsync(),
- PopulateGameProcessesAsync(),
- RefreshDiskAsync());
- });
+ await RunUiActionAsync(async () =>
+ {
+ await Task.WhenAll(
+ LoadDeviceListsAsync(),
+ PopulateGameProcessesAsync(),
+ RefreshDiskAsync());
+ });
+ _ = CheckForUpdatesAsync(force: false);
+ };
_diskTimer.Start();
}
@@ -438,9 +464,14 @@ private void LoadSettingsControls()
public void UpdateRuntimeState(
bool active,
string? activeCodec = null,
- string? activeCaptureSource = null)
+ string? activeCaptureSource = null,
+ int? availableReplaySeconds = null)
{
_runtimeActive = active;
+ if (availableReplaySeconds is not null)
+ _availableReplaySeconds = availableReplaySeconds.Value;
+ else if (!active)
+ _availableReplaySeconds = 0;
_updatingUi = true;
ReplayToggle.IsChecked = active;
SettingsReplayToggle.IsChecked = active;
@@ -492,7 +523,10 @@ public void UpdateRuntimeState(
FpsSummaryText.Text = $"{_config.FrameRate} FPS";
SaveButtonText.Text = Localization.Format(
"L.Save.Duration",
- FormatDuration(_config.BufferSeconds));
+ FormatDuration(
+ active
+ ? Math.Max(1, _availableReplaySeconds)
+ : _config.BufferSeconds));
HotkeySummaryText.Text = _config.Hotkey;
OutputFolderSummaryText.Text = _config.OutputDirectory;
}
@@ -559,9 +593,204 @@ private void OnLanguageChanged()
ApplyHardwareCapabilities();
UpdateCaptureSourceState();
UpdateRuntimeState(_runtimeActive);
+ RenderUpdateStatus();
_ = RefreshDiskAsync();
}
+ private void GitHub_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = UpdateService.RepositoryUrl,
+ UseShellExecute = true,
+ });
+ AnimatePress(GitHubButton);
+ }
+ catch (Exception exception)
+ {
+ HandleUiActionError("Open GitHub repository", exception);
+ }
+ }
+
+ private async void UpdateVersion_Click(
+ object sender,
+ RoutedEventArgs e)
+ {
+ if (_updateCheckInProgress || _updateInstallInProgress)
+ return;
+
+ AnimatePress(UpdateVersionButton);
+ if (_availableUpdate is null)
+ {
+ await CheckForUpdatesAsync(force: true);
+ return;
+ }
+
+ _updateInstallInProgress = true;
+ _updateDisplayState = UpdateDisplayState.Downloading;
+ _updateProgress = 0;
+ RenderUpdateStatus();
+
+ try
+ {
+ var progress = new Progress(value =>
+ {
+ _updateProgress = Math.Clamp(value, 0, 100);
+ RenderUpdateStatus();
+ });
+ await _installUpdate(
+ _availableUpdate,
+ progress,
+ _lifetimeCts.Token);
+ _updateDisplayState = UpdateDisplayState.Installing;
+ RenderUpdateStatus();
+ }
+ catch (OperationCanceledException)
+ when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Update installation failed: {exception}");
+ _updateInstallInProgress = false;
+ _updateDisplayState = UpdateDisplayState.Available;
+ RenderUpdateStatus();
+ ShowError(
+ Localization.Text("L.Update.InstallFailedTitle"),
+ exception.Message);
+ }
+ }
+
+ private async Task CheckForUpdatesAsync(bool force)
+ {
+ if (_updateCheckInProgress || _updateInstallInProgress)
+ return;
+
+ _updateCheckInProgress = true;
+ _updateDisplayState = UpdateDisplayState.Checking;
+ RenderUpdateStatus();
+ try
+ {
+ _availableUpdate = await _checkForUpdates(
+ force,
+ _lifetimeCts.Token);
+ _updateDisplayState = _availableUpdate is null
+ ? UpdateDisplayState.Current
+ : UpdateDisplayState.Available;
+ RenderUpdateStatus();
+ if (_availableUpdate is not null)
+ {
+ AnimatePress(UpdateVersionButton);
+ var pulse = new DoubleAnimation(
+ 0.28,
+ 1,
+ TimeSpan.FromMilliseconds(420))
+ {
+ AutoReverse = true,
+ RepeatBehavior = new RepeatBehavior(2),
+ };
+ UpdateStatusDot.BeginAnimation(
+ OpacityProperty,
+ pulse);
+ }
+ }
+ catch (OperationCanceledException)
+ when (_lifetimeCts.IsCancellationRequested)
+ {
+ // Window is closing.
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"Update check failed: {exception}");
+ _availableUpdate = null;
+ _updateDisplayState = UpdateDisplayState.CheckFailed;
+ RenderUpdateStatus();
+ if (force)
+ {
+ ShowError(
+ Localization.Text("L.Update.CheckFailedTitle"),
+ exception.Message);
+ }
+ }
+ finally
+ {
+ _updateCheckInProgress = false;
+ if (!_lifetimeCts.IsCancellationRequested)
+ RenderUpdateStatus();
+ }
+ }
+
+ private void RenderUpdateStatus()
+ {
+ string current = UpdateService.CurrentVersionText;
+ string? available = _availableUpdate is null
+ ? null
+ : UpdateService.FormatVersion(_availableUpdate.Version);
+
+ UpdateVersionButton.Tag = _updateDisplayState switch
+ {
+ UpdateDisplayState.Available => "available",
+ UpdateDisplayState.Checking or
+ UpdateDisplayState.Downloading or
+ UpdateDisplayState.Installing => "busy",
+ _ => "current",
+ };
+ UpdateStatusDot.Visibility = _updateDisplayState is
+ UpdateDisplayState.Available or
+ UpdateDisplayState.Downloading or
+ UpdateDisplayState.Installing
+ ? Visibility.Visible
+ : Visibility.Collapsed;
+
+ switch (_updateDisplayState)
+ {
+ case UpdateDisplayState.Checking:
+ UpdateStatusText.Text =
+ $"v{current} · {Localization.Text("L.Update.Checking")}";
+ UpdateVersionButton.ToolTip =
+ Localization.Text("L.Update.CheckingTip");
+ break;
+ case UpdateDisplayState.Available:
+ UpdateStatusText.Text = Localization.Format(
+ "L.Update.Available",
+ available ?? current);
+ UpdateVersionButton.ToolTip = Localization.Format(
+ "L.Update.AvailableTip",
+ available ?? current);
+ break;
+ case UpdateDisplayState.Downloading:
+ UpdateStatusText.Text = Localization.Format(
+ "L.Update.Downloading",
+ _updateProgress);
+ UpdateVersionButton.ToolTip = Localization.Format(
+ "L.Update.DownloadingTip",
+ available ?? current);
+ break;
+ case UpdateDisplayState.Installing:
+ UpdateStatusText.Text =
+ Localization.Text("L.Update.Installing");
+ UpdateVersionButton.ToolTip =
+ Localization.Text("L.Update.InstallingTip");
+ break;
+ case UpdateDisplayState.CheckFailed:
+ UpdateStatusText.Text = $"v{current}";
+ UpdateVersionButton.ToolTip =
+ Localization.Text("L.Update.CheckFailedTip");
+ break;
+ default:
+ UpdateStatusText.Text = $"v{current}";
+ UpdateVersionButton.ToolTip = _updateCheckInProgress
+ ? Localization.Text("L.Update.CheckingTip")
+ : Localization.Format(
+ "L.Update.UpToDateTip",
+ current);
+ break;
+ }
+ }
+
private void ShowSettings()
{
LoadSettingsControls();
@@ -1261,6 +1490,16 @@ private string LocalizedCaptureSource(string? _) =>
? "L.Video.GameLower"
: "L.Video.DesktopLower");
+ private enum UpdateDisplayState
+ {
+ Current,
+ Checking,
+ Available,
+ Downloading,
+ Installing,
+ CheckFailed,
+ }
+
private sealed record AudioDeviceSelection(bool IsSystem, string Id);
private sealed record DeviceListsSnapshot(
diff --git a/src/Captail/Themes/Theme.xaml b/src/Captail/Themes/Theme.xaml
index 4b35740..3d2378f 100644
--- a/src/Captail/Themes/Theme.xaml
+++ b/src/Captail/Themes/Theme.xaml
@@ -81,6 +81,8 @@
M9 2h6v12H9z M5 10v1a7 7 0 0 0 14 0v-1 M12 18v4M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+