diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9cf65a..5f9f15f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ All notable user-facing changes are documented here.
## [Unreleased]
+## [0.2.1] - 2026-08-21
+
+### Added
+
+- **Replay player:** Open a recent replay for immediate playback without entering edit mode, then continue into Trim when needed. The player supports seeking, fullscreen playback, keyboard controls, and temporary speed feedback from `0.25×` to `2×`.
+- **Replay-off game reminder:** An optional notification warns once when Captail detects a game while Instant Replay is disabled. It never starts recording without user action.
+- **Unsaved-settings guard:** Editing settings reveals a compact notice with Cancel and Done actions in the title bar. Close, `Alt+F4`, and `Esc` attempts keep the settings open and use a short shake to draw attention to pending changes.
+
+### Improved
+
+- **Lower Game Capture idle load:** When no plausible game is running, Captail releases the replay output and keeps only a low-rate detector active. The normal replay pipeline resumes automatically after a game is detected.
+- **Cleaner secondary actions:** GitHub, bug reports, feature requests, and privacy information now live in one compact About menu while the version and update state remain directly visible.
+- **Replay status indicator:** Removed the dark halo and made the indicator fully opaque for consistent contrast on bright backgrounds.
+
+### Fixed
+
+- Prevented Game Capture from treating unrelated fullscreen applications as games while waiting for a real game candidate.
+- Generated unplated Microsoft Store taskbar icons at every required target size and added package validation so placeholder-sized icons cannot ship unnoticed.
+- Prevented changed settings from being lost when the settings window is dismissed accidentally.
+
## [0.2.0] - 2026-08-14
### Added
diff --git a/README.md b/README.md
index 3004f94..b39784a 100644
--- a/README.md
+++ b/README.md
@@ -30,18 +30,16 @@
Captail is a focused alternative to NVIDIA ShadowPlay Instant Replay. It keeps the latest seconds or minutes in a rolling buffer, then saves them when you press a hotkey. No scenes, streaming setup, account, cloud upload, analytics, or telemetry.
> [!WARNING]
-> Captail `v0.2.0` is an early public preview. Recording works, but bugs and hardware-specific problems are expected. NVIDIA RTX 40 and RTX 50 series are tested; other GPUs need broader public testing.
+> Captail `v0.2.1` is an early public preview. Recording works, but bugs and hardware-specific problems are expected. NVIDIA RTX 40 and RTX 50 series are tested; other GPUs need broader public testing.
-## What's new in Captail 0.2.0?
+## What's new in Captail 0.2.1?
-- Use Captail in 11 languages through a compact native-name language menu. First launch follows the Windows language when supported.
-- Identify matching physical displays directly from the monitor selector.
-- Keep Desktop capture active when a fullscreen game is recognized but cannot provide usable Game Capture frames.
-- Send a useful bug report with system and recording details plus a short sanitized log excerpt already filled in.
-- Suggest focused improvements through the new Feature link beside Report bug.
-- Keep the replay indicator in the correct game or desktop corner without covering the taskbar or newer system overlays.
-
-Simplified Chinese support was contributed by [@zhuyouyi](https://github.com/zhuyouyi) through [PR #24](https://github.com/FaulMit/captail/pull/24).
+- Open any recent replay in a fast built-in player, seek through it, change playback speed with the arrow keys, switch to fullscreen, or continue directly into Trim.
+- Let Game Capture sleep when no plausible game is running, reducing idle GPU and memory use while keeping a lightweight detector ready.
+- Receive an optional reminder when a game starts while Instant Replay is disabled.
+- Keep accidental settings changes under control with clear Cancel and Done actions. Closing settings with pending edits now shakes the window instead of silently losing them.
+- Access GitHub, bug reports, feature requests, and privacy information from one compact About menu.
+- Get a cleaner replay indicator and correctly sized Microsoft Store taskbar icons.
## Is Captail for me?
@@ -71,6 +69,10 @@ Captail is not a streaming application, scene compositor, DRM bypass, or cloud c
+
+
+
+
@@ -114,6 +116,8 @@ Both hotkeys are configurable. Double-click the tray icon to reopen Captail.
The optional replay status indicator keeps recording state visible over games and desktop apps. It is enabled by default, can sit in any screen corner, ignores mouse input, and briefly confirms a successful save. Disable it or change its position in Replay settings.
+Enable **Warn when a game starts** if you want one quiet reminder when Captail detects a game while Instant Replay is disabled. The reminder never enables recording automatically.
+
On first launch, Captail follows your Windows language when it supports it; otherwise it starts in English. Use the compact language control in the title bar to switch between English, Russian, Ukrainian, Simplified Chinese, Spanish, Brazilian Portuguese, German, French, Japanese, Korean, and Polish.
If similarly sized monitors are hard to distinguish, open Video settings and select **Identify displays** beside the monitor list. Captail briefly places a large number on each matching screen.
@@ -123,9 +127,9 @@ If similarly sized monitors are hard to distinguish, open Video settings and sel
| Mode | When no game is detected | When a fullscreen game appears | Audio source |
| --- | --- | --- | --- |
| **Desktop** | Records the selected monitor | Uses direct Game Capture when usable; otherwise keeps desktop video active while recognizing supported fullscreen games | Selected Windows output device |
-| **Game Capture** | Waits without recording the desktop | Captures the detected game directly | Detected game audio |
+| **Game Capture** | Keeps a lightweight game detector ready while the replay buffer sleeps | Wakes the replay buffer and captures the detected game directly | Detected game audio |
-The replay buffer stays armed in both modes. Use **Desktop** when you want continuous coverage; use **Game Capture** when privacy outside games matters more. When a fullscreen game cannot be hooked, Desktop mode favors uninterrupted video over pretending direct Game Capture is working.
+Use **Desktop** when you want continuous coverage; use **Game Capture** when privacy and low idle resource use outside games matter more. In Game Capture mode, Captail wakes the replay buffer only after a plausible game appears. When a fullscreen game cannot be hooked, Desktop mode favors uninterrupted video over pretending direct Game Capture is working.
## What happens when capture breaks?
@@ -193,6 +197,8 @@ Yes. The main window lists supported files from the selected replay folder in a
- Open a replay in File Explorer.
- Move it to the Recycle Bin after confirmation.
+- Play it immediately without entering edit mode.
+- Seek, pause, use fullscreen, and change playback speed from `0.25×` to `2×` with the keyboard.
- Scrub through a responsive preview.
- Select one trim range with draggable handles.
- Keep or remove each audio track.
@@ -273,7 +279,7 @@ Review logs before attaching them and remove personal paths or other sensitive i
Compatibility reports for older NVIDIA, AMD, and Intel GPUs are useful even when everything works.
-Have an improvement rather than a bug? Use the **Feature** footer action to open Captail's focused feature-request template.
+Have an improvement rather than a bug? Open **About → Feature** to use Captail's focused feature-request template.
## How do I build or contribute?
diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md
index ecfad33..618021a 100644
--- a/docs/SCREENSHOTS.md
+++ b/docs/SCREENSHOTS.md
@@ -11,6 +11,7 @@ Every release must ship a fresh screenshot set captured from the exact release-c
| `docs/captail-main.png` | Instant Replay active | Complete main window showing `AV1 · 4K · 240 FPS`, enabled system and microphone audio, current version, Save button, and several real recent-replay rows. |
| `docs/captail-settings-video.png` | Video settings | Hardware AV1 selected at `3840 × 2160` and `240 FPS`, plus source, bitrate, quality profile, display, and resolution controls. |
| `docs/captail-settings-audio.png` | Audio and replay settings | Enabled system audio and microphone, separate tracks, volume, microphone boost, buffer, storage, and hotkey controls where they fit naturally. Use a second real settings screenshot when one readable frame cannot contain them. |
+| `docs/captail-player.png` | Real replay loaded in Preview | Working AV1 4K 240 FPS playback, seek bar, keyboard help, Trim action, fullscreen control, and matching media details. |
| `docs/captail-editor.png` | Real replay loaded | Working preview of a real AV1 4K 240 FPS replay, selected trim range, video timeline, available audio tracks, matching media details, and save actions. |
The next refresh should migrate the two older settings JPEG files to the stable PNG names above. After that migration, overwrite the stable files on every release instead of adding versioned duplicates.
diff --git a/docs/captail-editor.png b/docs/captail-editor.png
index 701a387..e4c83a2 100644
Binary files a/docs/captail-editor.png and b/docs/captail-editor.png differ
diff --git a/docs/captail-main.png b/docs/captail-main.png
index 108194b..3ed64ad 100644
Binary files a/docs/captail-main.png and b/docs/captail-main.png differ
diff --git a/docs/captail-player.png b/docs/captail-player.png
new file mode 100644
index 0000000..c46a6ac
Binary files /dev/null and b/docs/captail-player.png differ
diff --git a/docs/captail-settings-audio.png b/docs/captail-settings-audio.png
index 2ffceff..1c000e8 100644
Binary files a/docs/captail-settings-audio.png and b/docs/captail-settings-audio.png differ
diff --git a/docs/captail-settings-video.png b/docs/captail-settings-video.png
index e55c120..5ff7d76 100644
Binary files a/docs/captail-settings-video.png and b/docs/captail-settings-video.png differ
diff --git a/src/Captail/App.xaml.cs b/src/Captail/App.xaml.cs
index b70893e..cf73feb 100644
--- a/src/Captail/App.xaml.cs
+++ b/src/Captail/App.xaml.cs
@@ -37,6 +37,10 @@ public partial class App : Application
private int _recoveryFailures;
private int _recoveryInProgress;
private int _captureStateRefreshInProgress;
+ private string _pendingReplayOffGame = "";
+ private int _pendingReplayOffGameSamples;
+ private readonly HashSet _warnedReplayOffGames =
+ new(StringComparer.OrdinalIgnoreCase);
private OverlayNotificationWindow? _overlayNotification;
private ReplayStatusIndicatorWindow? _recordingIndicator;
private readonly UpdateService _updateService = new();
@@ -78,6 +82,9 @@ protected override async void OnStartup(StartupEventArgs e)
argument => argument.StartsWith(
"--qa-game-capture=",
StringComparison.OrdinalIgnoreCase));
+ bool gameCaptureIdleTest = e.Args.Contains(
+ "--qa-game-capture-idle",
+ StringComparer.OrdinalIgnoreCase);
bool replaySegmentsTest = e.Args.Contains(
"--qa-replay-segments",
StringComparer.OrdinalIgnoreCase);
@@ -116,6 +123,13 @@ protected override async void OnStartup(StartupEventArgs e)
StringComparison.OrdinalIgnoreCase))
?["--qa-clip-editor=".Length..];
bool clipEditorTest = !string.IsNullOrWhiteSpace(clipEditorTestPath);
+ string? replayPlayerTestPath = e.Args
+ .FirstOrDefault(argument => argument.StartsWith(
+ "--qa-replay-player=",
+ StringComparison.OrdinalIgnoreCase))
+ ?["--qa-replay-player=".Length..];
+ bool replayPlayerTest =
+ !string.IsNullOrWhiteSpace(replayPlayerTestPath);
string? audioMixTestPath = e.Args
.FirstOrDefault(argument => argument.StartsWith(
"--qa-audio-mix=",
@@ -144,6 +158,7 @@ protected override async void OnStartup(StartupEventArgs e)
const bool codecTest = false;
const bool capabilityModelTest = false;
const bool gameCaptureTest = false;
+ const bool gameCaptureIdleTest = false;
const bool replaySegmentsTest = false;
const bool fileRetryTest = false;
const bool automaticCapturePolicyTest = false;
@@ -151,6 +166,7 @@ protected override async void OnStartup(StartupEventArgs e)
const bool localizationTest = false;
const bool updateCheckTest = false;
const bool clipEditorTest = false;
+ const bool replayPlayerTest = false;
const bool audioMixTest = false;
const bool previewGeometryTest = false;
const bool trimOverwriteTest = false;
@@ -166,8 +182,9 @@ protected override async void OnStartup(StartupEventArgs e)
backgroundLaunch,
shutdownExisting,
_uiOnly || faultTest || codecTest || capabilityModelTest ||
- gameCaptureTest || replaySegmentsTest || updateCheckTest ||
- clipEditorTest || audioMixTest || previewGeometryTest ||
+ gameCaptureTest || gameCaptureIdleTest ||
+ replaySegmentsTest || updateCheckTest ||
+ clipEditorTest || replayPlayerTest || audioMixTest || previewGeometryTest ||
fileRetryTest || trimOverwriteTest ||
automaticCapturePolicyTest || replayRoutingTest ||
localizationTest))
@@ -222,6 +239,11 @@ protected override async void OnStartup(StartupEventArgs e)
RunGameCaptureTest(e.Args);
return;
}
+ if (gameCaptureIdleTest)
+ {
+ RunGameCaptureIdleTest();
+ return;
+ }
if (replaySegmentsTest)
{
RunReplaySegmentsTest();
@@ -260,6 +282,13 @@ await _updateService.CheckAsync(
await RunClipEditorTestAsync(clipEditorTestPath!);
return;
}
+ if (replayPlayerTest)
+ {
+ await RunClipEditorTestAsync(
+ replayPlayerTestPath!,
+ ClipWindowMode.Preview);
+ return;
+ }
if (audioMixTest)
{
await RunAudioMixTestAsync(audioMixTestPath!);
@@ -414,6 +443,7 @@ private void RunAutomaticCapturePolicyTest()
"firefox.exe", "explorer.exe", "dwm.exe", "Spotify.exe",
"vlc.exe", "mpv.exe", "obs64.exe", "Captail.exe",
"ApplicationFrameHost.exe", "ShellExperienceHost.exe", "SearchHost.exe",
+ "ScreenClippingHost.exe", "SnippingTool.exe",
];
string[] accepted =
[
@@ -460,6 +490,22 @@ private void RunAutomaticCapturePolicyTest()
"RenderTool.exe",
@"C:\Tools\RenderTool.exe",
isFullscreen: true);
+ bool fullscreenGameWakesDetector = ObsReplayEngine.ShouldWakeGameCapture(
+ new Interop.CaptureInterop.ForegroundAppInfo(
+ "ExampleGame.exe",
+ @"C:\Games\ExampleGame.exe",
+ true));
+ bool windowedSteamGameWakesDetector = ObsReplayEngine.ShouldWakeGameCapture(
+ new Interop.CaptureInterop.ForegroundAppInfo(
+ "cs2.exe",
+ @"D:\SteamLibrary\steamapps\common\Counter-Strike Global Offensive\game\bin\win64\cs2.exe",
+ false));
+ bool fullscreenTelegramDoesNotWakeDetector =
+ !ObsReplayEngine.ShouldWakeGameCapture(
+ new Interop.CaptureInterop.ForegroundAppInfo(
+ "Telegram.exe",
+ @"C:\Apps\Telegram.exe",
+ true));
bool passed = falsePositives.Length == 0 &&
falseNegatives.Length == 0 &&
!ObsReplayEngine.IsAutomaticCaptureCandidate("") &&
@@ -467,7 +513,10 @@ private void RunAutomaticCapturePolicyTest()
missingVideoRejected && blockedSteamGameAccepted &&
windowedSteamGameRejected &&
fullscreenTelegramRejected &&
- unknownFullscreenAppRejected;
+ unknownFullscreenAppRejected &&
+ fullscreenGameWakesDetector &&
+ windowedSteamGameWakesDetector &&
+ fullscreenTelegramDoesNotWakeDetector;
Log.Write(
$"AUTO_CAPTURE_POLICY_TEST {(passed ? "PASS" : "FAIL")}: " +
$"falsePositives={string.Join(',', falsePositives)}, " +
@@ -478,7 +527,10 @@ private void RunAutomaticCapturePolicyTest()
$"blockedSteamGameAccepted={blockedSteamGameAccepted}, " +
$"windowedSteamGameRejected={windowedSteamGameRejected}, " +
$"fullscreenTelegramRejected={fullscreenTelegramRejected}, " +
- $"unknownFullscreenAppRejected={unknownFullscreenAppRejected}");
+ $"unknownFullscreenAppRejected={unknownFullscreenAppRejected}, " +
+ $"fullscreenGameWakesDetector={fullscreenGameWakesDetector}, " +
+ $"windowedSteamGameWakesDetector={windowedSteamGameWakesDetector}, " +
+ $"fullscreenTelegramDoesNotWakeDetector={fullscreenTelegramDoesNotWakeDetector}");
Shutdown(passed ? 0 : 22);
}
@@ -680,7 +732,9 @@ mixedVideo is not null &&
}
}
- private async Task RunClipEditorTestAsync(string path)
+ private async Task RunClipEditorTestAsync(
+ string path,
+ ClipWindowMode mode = ClipWindowMode.Trim)
{
string fullPath = Path.GetFullPath(path);
if (!File.Exists(fullPath))
@@ -701,7 +755,8 @@ private async Task RunClipEditorTestAsync(string path)
new ReplayLibrary(ffmpeg),
file.DirectoryName!,
clip,
- saved => Log.Write($"CLIP_EDITOR_QA saved={saved}"));
+ saved => Log.Write($"CLIP_EDITOR_QA saved={saved}"),
+ mode);
MainWindow = window;
window.ShowDialog();
Shutdown(0);
@@ -956,31 +1011,38 @@ private async void RunGameCaptureTest(string[] args)
};
if (!TryStartPipeline(showError: false))
throw new InvalidOperationException("OBS Game Capture did not start.");
+ ObsReplayEngine engine = _obs ?? throw new InvalidOperationException(
+ "OBS Game Capture engine is missing.");
DateTime hookDeadline = DateTime.UtcNow.AddSeconds(8);
- while (!_obs!.IsGameHooked && DateTime.UtcNow < hookDeadline)
+ while (DateTime.UtcNow < hookDeadline)
+ {
+ engine.RefreshCaptureState();
+ if (engine.IsGameHooked && engine.IsActive)
+ break;
await Task.Delay(100);
- if (_obs.IsGameHooked)
+ }
+ if (engine.IsGameHooked)
await Task.Delay(TimeSpan.FromSeconds(2));
- uint totalBefore = _obs.TotalRenderedFrames;
- uint laggedBefore = _obs.LaggedRenderedFrames;
+ uint totalBefore = engine.TotalRenderedFrames;
+ uint laggedBefore = engine.LaggedRenderedFrames;
await Task.Delay(TimeSpan.FromSeconds(6));
- uint totalAfter = _obs.TotalRenderedFrames;
- uint laggedAfter = _obs.LaggedRenderedFrames;
+ uint totalAfter = engine.TotalRenderedFrames;
+ uint laggedAfter = engine.LaggedRenderedFrames;
uint totalDelta = totalAfter - totalBefore;
uint laggedDelta = laggedAfter - laggedBefore;
double steadyLagPercent = totalDelta == 0
? 100
: laggedDelta * 100d / totalDelta;
- string path = await _obs!.SaveReplayAsync();
+ string path = await engine.SaveReplayAsync();
bool passed = File.Exists(path) &&
new FileInfo(path).Length > 0 &&
- _obs.IsGameHooked &&
- _obs.EncodedFrameCount > 0 &&
+ engine.IsGameHooked &&
+ engine.EncodedFrameCount > 0 &&
steadyLagPercent < 10;
Log.Write(
$"OBS_GAME_TEST {(passed ? "PASS" : "FAIL")}: " +
- $"hooked={_obs.IsGameHooked}, frames={_obs.EncodedFrameCount}, " +
+ $"hooked={engine.IsGameHooked}, frames={engine.EncodedFrameCount}, " +
$"steadyLag={laggedDelta}/{totalDelta} ({steadyLagPercent:0.0}%), " +
$"path={path}");
Shutdown(passed ? 0 : 8);
@@ -992,6 +1054,50 @@ private async void RunGameCaptureTest(string[] args)
}
}
+ private async void RunGameCaptureIdleTest()
+ {
+ try
+ {
+ string root = Path.Combine(
+ Path.GetTempPath(),
+ "Captail",
+ $"obs_game_idle_{Environment.ProcessId}");
+ _config = new Config
+ {
+ ReplayEnabled = true,
+ BufferSeconds = 300,
+ FrameRate = 240,
+ RecordingResolution = "1080p",
+ BitrateMbps = 50,
+ Codec = "av1",
+ CaptureSource = "game",
+ CaptureSystemAudio = true,
+ CaptureMicrophone = false,
+ OutputDirectory = root,
+ };
+ if (!TryStartPipeline(showError: false))
+ throw new InvalidOperationException("OBS Game Capture did not start.");
+
+ await Task.Delay(TimeSpan.FromSeconds(3));
+ bool passed = !_obs!.IsGameHooked &&
+ !_obs.IsActive &&
+ !_obs.IsGameCaptureDetectorActive &&
+ _obs.BufferedBytes == 0 &&
+ _obs.EncodedFrameCount == 0;
+ Log.Write(
+ $"OBS_GAME_IDLE_TEST {(passed ? "PASS" : "FAIL")}: " +
+ $"hooked={_obs.IsGameHooked}, active={_obs.IsActive}, " +
+ $"detector={_obs.IsGameCaptureDetectorActive}, " +
+ $"frames={_obs.EncodedFrameCount}, bytes={_obs.BufferedBytes}");
+ Shutdown(passed ? 0 : 18);
+ }
+ catch (Exception exception)
+ {
+ Log.Write($"OBS_GAME_IDLE_TEST FAIL: {exception}");
+ Shutdown(19);
+ }
+ }
+
private async void RunFaultRecoveryTest()
{
try
@@ -1473,7 +1579,7 @@ private void StartCaptureStateMonitor()
private async Task RefreshAutomaticCaptureStateSafeAsync()
{
- if (_uiOnly || !IsReplayRunning ||
+ if (_uiOnly ||
Volatile.Read(ref _exiting) != 0 ||
Interlocked.Exchange(ref _captureStateRefreshInProgress, 1) != 0)
{
@@ -1482,12 +1588,15 @@ private async Task RefreshAutomaticCaptureStateSafeAsync()
try
{
+ RefreshReplayOffGameWarning();
+ if (!IsReplayRunning)
+ return;
if (!await _pipelineGate.WaitAsync(0))
return;
try
{
ObsReplayEngine? engine = _obs;
- if (engine is null || !engine.IsAutomaticCapture)
+ if (engine is null)
return;
(bool changed, string description) =
await RunOnObsThreadAsync(() =>
@@ -1516,6 +1625,62 @@ await RunOnObsThreadAsync(() =>
}
}
+ private void RefreshReplayOffGameWarning()
+ {
+ Config? config = _config;
+ if (config is null ||
+ !config.WarnWhenGameStartsWithReplayOff ||
+ config.ReplayEnabled ||
+ IsReplayRunning)
+ {
+ _pendingReplayOffGame = "";
+ _pendingReplayOffGameSamples = 0;
+ if (IsReplayRunning)
+ _warnedReplayOffGames.Clear();
+ return;
+ }
+
+ Interop.CaptureInterop.ForegroundAppInfo foreground =
+ Interop.CaptureInterop.ForegroundApplication();
+ if (!ObsReplayEngine.ShouldWakeGameCapture(foreground))
+ {
+ _pendingReplayOffGame = "";
+ _pendingReplayOffGameSamples = 0;
+ return;
+ }
+
+ string gameKey = string.IsNullOrWhiteSpace(foreground.FullPath)
+ ? foreground.Executable
+ : foreground.FullPath;
+ if (_warnedReplayOffGames.Contains(gameKey))
+ return;
+
+ if (!string.Equals(
+ _pendingReplayOffGame,
+ gameKey,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ _pendingReplayOffGame = gameKey;
+ _pendingReplayOffGameSamples = 1;
+ return;
+ }
+
+ _pendingReplayOffGameSamples++;
+ if (_pendingReplayOffGameSamples < 4)
+ return;
+
+ _warnedReplayOffGames.Add(gameKey);
+ _pendingReplayOffGame = "";
+ _pendingReplayOffGameSamples = 0;
+ string gameName = Path.GetFileNameWithoutExtension(foreground.Executable);
+ ShowOverlayNotification(
+ "!",
+ Localization.Text("L.Notify.ReplayOffGameTitle"),
+ Localization.Format("L.Notify.ReplayOffGameDetail", gameName),
+ OverlayTone.Warning,
+ 5200);
+ }
+
private async Task MonitorPipelineSafeAsync()
{
try
@@ -1821,10 +1986,10 @@ private void OpenSettings()
$"sha256:{new string('0', 64)}");
return Task.FromResult(
new UpdateRelease(
- new Version(0, 2, 0),
- "v0.2.0",
+ new Version(0, 2, 2),
+ "v0.2.2",
new Uri(
- "https://github.com/FaulMit/captail/releases/tag/v0.2.0"),
+ "https://github.com/FaulMit/captail/releases/tag/v0.2.2"),
true,
asset,
asset,
@@ -2220,7 +2385,10 @@ private void UpdateUiState()
: Localization.Text("L.Tray.Disabled");
}
if (_saveMenuItem is not null)
+ {
_saveMenuItem.InputGestureText = _config?.Hotkey ?? "";
+ _saveMenuItem.IsEnabled = active && availableReplaySeconds > 0;
+ }
if (_toggleMenuItem is not null)
_toggleMenuItem.InputGestureText =
_config?.ToggleReplayHotkey ?? "";
diff --git a/src/Captail/Captail.csproj b/src/Captail/Captail.csproj
index bb672e4..b3498b0 100644
--- a/src/Captail/Captail.csproj
+++ b/src/Captail/Captail.csproj
@@ -15,7 +15,7 @@
CaptailAssets\Captail.icoapp.manifest
- 0.2.0
+ 0.2.1true$(DefineConstants);MICROSOFT_STORE
diff --git a/src/Captail/ClipEditorWindow.xaml b/src/Captail/ClipEditorWindow.xaml
index fa65780..a394491 100644
--- a/src/Captail/ClipEditorWindow.xaml
+++ b/src/Captail/ClipEditorWindow.xaml
@@ -67,6 +67,38 @@
+
+
+
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -455,6 +498,37 @@
Style="{StaticResource ToggleSwitch}"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -998,9 +1072,6 @@
-
@@ -1008,52 +1079,88 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
_monitors = [];
private readonly List _displayIdentifierWindows = [];
+ private bool _settingsDirty;
+ private bool _savedAutostartEnabled;
+ private bool _allowClose;
+ private bool _closeAfterUnsavedResolution;
+ private bool _dirtyRefreshQueued;
public bool Applied { get; private set; }
@@ -83,6 +90,7 @@ internal SettingsWindow(
_runtimeActive = runtimeActive;
InitializeComponent();
+ AttachSettingsChangeTracking();
LanguageList.ItemsSource = Localization.SupportedLanguages;
UpdateLanguageMenuSelection();
_replayLibrary = new ReplayLibrary(new FfmpegAdapter());
@@ -374,6 +382,7 @@ private void LoadSettingsControls()
SelectByTag(AudioTrackModeBox, _config.SeparateAudioTracks ? "separate" : "mixed");
SettingsReplayToggle.IsChecked = _config.ReplayEnabled;
+ WarnGameOffBox.IsChecked = _config.WarnWhenGameStartsWithReplayOff;
RecordingIndicatorBox.IsChecked = _config.ShowRecordingIndicator;
SelectRadioByTag(
RecordingIndicatorPositionOptions,
@@ -444,7 +453,7 @@ public void UpdateRuntimeState(
: Localization.Text("L.Status.Idle");
StatusRing.Stroke = FindBrush(active ? "AccentBrush" : "RingIdleBrush");
StatusDot.Fill = FindBrush(active ? "AccentBrush" : "RingIdleBrush");
- SaveReplayButton.IsEnabled = active;
+ SaveReplayButton.IsEnabled = active && _availableReplaySeconds > 0;
AnimateRecordingState(active);
SystemSourceChip.IsChecked = _config.CaptureSystemAudio;
@@ -465,7 +474,7 @@ public void UpdateRuntimeState(
SaveButtonText.Text = Localization.Format(
"L.Save.Duration",
FormatDuration(
- active
+ active && _availableReplaySeconds > 0
? Math.Max(1, _availableReplaySeconds)
: _config.BufferSeconds));
HotkeySummaryText.Text = _config.Hotkey;
@@ -536,6 +545,327 @@ private void LanguagePopup_Closed(object? sender, EventArgs e)
UpdateLanguageMenuSelection();
}
+ private CustomPopupPlacement[] AboutPopup_Placement(
+ Size popupSize,
+ Size targetSize,
+ Point offset)
+ {
+ const double gap = 7;
+ return
+ [
+ new CustomPopupPlacement(
+ new Point(
+ targetSize.Width - popupSize.Width,
+ -popupSize.Height - gap),
+ PopupPrimaryAxis.Horizontal)
+ ];
+ }
+
+ private void AboutPopup_Opened(object? sender, EventArgs e)
+ {
+ AboutPopupPanel.BeginAnimation(
+ OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(110)));
+ if (AboutPopupPanel.RenderTransform is TranslateTransform translate)
+ {
+ translate.BeginAnimation(
+ TranslateTransform.YProperty,
+ new DoubleAnimation(5, 0, TimeSpan.FromMilliseconds(145))
+ {
+ EasingFunction = new CubicEase
+ {
+ EasingMode = EasingMode.EaseOut,
+ },
+ });
+ }
+ Dispatcher.BeginInvoke(
+ DispatcherPriority.Input,
+ () => GitHubButton.Focus());
+ }
+
+ private void AboutPopup_Closed(object? sender, EventArgs e)
+ {
+ AboutPopupPanel.BeginAnimation(OpacityProperty, null);
+ AboutPopupPanel.Opacity = 0;
+ if (AboutPopupPanel.RenderTransform is TranslateTransform translate)
+ {
+ translate.BeginAnimation(TranslateTransform.YProperty, null);
+ translate.Y = 5;
+ }
+ }
+
+ private void AttachSettingsChangeTracking()
+ {
+ SettingsPanel.AddHandler(
+ ToggleButton.CheckedEvent,
+ new RoutedEventHandler(SettingsControl_Changed),
+ handledEventsToo: true);
+ SettingsPanel.AddHandler(
+ ToggleButton.UncheckedEvent,
+ new RoutedEventHandler(SettingsControl_Changed),
+ handledEventsToo: true);
+ SettingsPanel.AddHandler(
+ Selector.SelectionChangedEvent,
+ new SelectionChangedEventHandler(SettingsSelection_Changed),
+ handledEventsToo: true);
+ SettingsPanel.AddHandler(
+ RangeBase.ValueChangedEvent,
+ new RoutedPropertyChangedEventHandler(SettingsValue_Changed),
+ handledEventsToo: true);
+ }
+
+ private void SettingsControl_Changed(object sender, RoutedEventArgs e) =>
+ QueueSettingsDirtyRefresh();
+
+ private void SettingsSelection_Changed(
+ object sender,
+ SelectionChangedEventArgs e) =>
+ QueueSettingsDirtyRefresh();
+
+ private void SettingsValue_Changed(
+ object sender,
+ RoutedPropertyChangedEventArgs e) =>
+ QueueSettingsDirtyRefresh();
+
+ private void QueueSettingsDirtyRefresh()
+ {
+ if (_updatingUi ||
+ SettingsPanel.Visibility != Visibility.Visible ||
+ _dirtyRefreshQueued)
+ {
+ return;
+ }
+
+ _dirtyRefreshQueued = true;
+ Dispatcher.BeginInvoke(() =>
+ {
+ _dirtyRefreshQueued = false;
+ RefreshSettingsDirtyState();
+ }, DispatcherPriority.DataBind);
+ }
+
+ private void RefreshSettingsDirtyState()
+ {
+ if (_updatingUi || SettingsPanel.Visibility != Visibility.Visible)
+ return;
+
+ Config pending = CreatePendingConfig();
+ bool dirty = !SettingsValuesEqual(pending, _config) ||
+ (AutostartBox.IsChecked == true) != _savedAutostartEnabled;
+ SetSettingsDirty(dirty);
+ }
+
+ private Config CreatePendingConfig()
+ {
+ Config candidate = _config.Clone();
+ candidate.ReplayEnabled = SettingsReplayToggle.IsChecked == true;
+ candidate.WarnWhenGameStartsWithReplayOff = WarnGameOffBox.IsChecked == true;
+ candidate.ShowRecordingIndicator = RecordingIndicatorBox.IsChecked == true;
+ candidate.RecordingIndicatorPosition = GetSelectedRadioTag(
+ RecordingIndicatorPositionOptions,
+ _config.RecordingIndicatorPosition);
+ candidate.BufferSeconds = GetSelectedRadioInt(
+ BufferOptions,
+ _config.BufferSeconds);
+ candidate.MaxReplaySizeMb = GetSelectedInt(ReplaySizeLimitBox, 0);
+ candidate.CaptureSource = GetSelectedTag(CaptureSourceBox, "desktop");
+ candidate.Codec = GetSelectedTag(CodecBox, _config.Codec);
+ candidate.BitrateMbps = GetSelectedInt(BitrateBox, _config.BitrateMbps);
+ candidate.FrameRate = GetSelectedRadioInt(FpsOptions, _config.FrameRate);
+ candidate.MonitorIndex = GetSelectedInt(MonitorBox, _config.MonitorIndex);
+ candidate.RecordingResolution = GetSelectedTag(ResolutionBox, "source");
+ candidate.CaptureSystemAudio = SystemAudioBox.IsChecked == true;
+ candidate.SystemAudioVolume = (int)Math.Round(SystemVolumeSlider.Value);
+ candidate.SystemAudioDeviceId = GetSelectedTag(AudioDeviceBox, "");
+ candidate.CaptureMicrophone = MicBox.IsChecked == true;
+ candidate.MicrophoneVolume = (int)Math.Round(MicVolumeSlider.Value);
+ candidate.MicrophoneBoostDb = (int)Math.Round(MicBoostSlider.Value);
+ candidate.MicrophoneDeviceId = GetSelectedTag(MicDeviceBox, "");
+ candidate.AudioCodec = GetSelectedTag(AudioCodecBox, "aac");
+ candidate.SeparateAudioTracks =
+ GetSelectedTag(AudioTrackModeBox, "mixed") == "separate";
+ candidate.OutputDirectory = _outputDirectory;
+ candidate.OrganizeReplaysByGame = OrganizeByGameBox.IsChecked == true;
+ candidate.Hotkey = _pendingSaveHotkey;
+ candidate.ToggleReplayHotkey = _pendingToggleHotkey;
+ return candidate;
+ }
+
+ private static bool SettingsValuesEqual(Config left, Config right) =>
+ left.ReplayEnabled == right.ReplayEnabled &&
+ left.WarnWhenGameStartsWithReplayOff == right.WarnWhenGameStartsWithReplayOff &&
+ left.ShowRecordingIndicator == right.ShowRecordingIndicator &&
+ string.Equals(
+ left.RecordingIndicatorPosition,
+ right.RecordingIndicatorPosition,
+ StringComparison.Ordinal) &&
+ left.BufferSeconds == right.BufferSeconds &&
+ left.MaxReplaySizeMb == right.MaxReplaySizeMb &&
+ string.Equals(left.CaptureSource, right.CaptureSource, StringComparison.Ordinal) &&
+ string.Equals(left.Codec, right.Codec, StringComparison.Ordinal) &&
+ left.BitrateMbps == right.BitrateMbps &&
+ left.FrameRate == right.FrameRate &&
+ left.MonitorIndex == right.MonitorIndex &&
+ string.Equals(
+ left.RecordingResolution,
+ right.RecordingResolution,
+ StringComparison.Ordinal) &&
+ left.CaptureSystemAudio == right.CaptureSystemAudio &&
+ left.SystemAudioVolume == right.SystemAudioVolume &&
+ string.Equals(
+ left.SystemAudioDeviceId,
+ right.SystemAudioDeviceId,
+ StringComparison.Ordinal) &&
+ left.CaptureMicrophone == right.CaptureMicrophone &&
+ left.MicrophoneVolume == right.MicrophoneVolume &&
+ left.MicrophoneBoostDb == right.MicrophoneBoostDb &&
+ string.Equals(
+ left.MicrophoneDeviceId,
+ right.MicrophoneDeviceId,
+ StringComparison.Ordinal) &&
+ string.Equals(left.AudioCodec, right.AudioCodec, StringComparison.Ordinal) &&
+ left.SeparateAudioTracks == right.SeparateAudioTracks &&
+ string.Equals(
+ left.OutputDirectory,
+ right.OutputDirectory,
+ StringComparison.OrdinalIgnoreCase) &&
+ left.OrganizeReplaysByGame == right.OrganizeReplaysByGame &&
+ string.Equals(left.Hotkey, right.Hotkey, StringComparison.Ordinal) &&
+ string.Equals(
+ left.ToggleReplayHotkey,
+ right.ToggleReplayHotkey,
+ StringComparison.Ordinal);
+
+ private void SetSettingsDirty(bool dirty, bool animate = true)
+ {
+ _settingsDirty = dirty;
+ if (dirty)
+ {
+ UnsavedChangesBar.BeginAnimation(OpacityProperty, null);
+ UnsavedChangesTranslate.BeginAnimation(TranslateTransform.YProperty, null);
+ if (UnsavedChangesBar.Visibility != Visibility.Visible)
+ {
+ UnsavedChangesBar.Visibility = Visibility.Visible;
+ UnsavedChangesBar.Opacity = animate ? 0 : 1;
+ UnsavedChangesTranslate.Y = animate ? -6 : 0;
+ if (animate)
+ {
+ UnsavedChangesBar.BeginAnimation(
+ OpacityProperty,
+ new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(150))
+ {
+ EasingFunction = new CubicEase
+ {
+ EasingMode = EasingMode.EaseOut,
+ },
+ });
+ UnsavedChangesTranslate.BeginAnimation(
+ TranslateTransform.YProperty,
+ new DoubleAnimation(-6, 0, TimeSpan.FromMilliseconds(190))
+ {
+ EasingFunction = new CubicEase
+ {
+ EasingMode = EasingMode.EaseOut,
+ },
+ });
+ }
+ }
+ else
+ {
+ UnsavedChangesBar.Opacity = 1;
+ UnsavedChangesTranslate.Y = 0;
+ }
+ return;
+ }
+
+ if (UnsavedChangesBar.Visibility != Visibility.Visible)
+ return;
+ UnsavedChangesBar.BeginAnimation(OpacityProperty, null);
+ UnsavedChangesTranslate.BeginAnimation(TranslateTransform.YProperty, null);
+ if (!animate)
+ {
+ UnsavedChangesBar.Visibility = Visibility.Collapsed;
+ UnsavedChangesBar.Opacity = 0;
+ UnsavedChangesTranslate.Y = -6;
+ return;
+ }
+
+ var fade = new DoubleAnimation(0, TimeSpan.FromMilliseconds(120));
+ fade.Completed += (_, _) =>
+ {
+ if (_settingsDirty)
+ return;
+ UnsavedChangesBar.Visibility = Visibility.Collapsed;
+ UnsavedChangesBar.Opacity = 0;
+ UnsavedChangesTranslate.Y = -6;
+ };
+ UnsavedChangesBar.BeginAnimation(OpacityProperty, fade);
+ }
+
+ private void PromptForUnsavedChanges(bool closeAfterResolution)
+ {
+ _closeAfterUnsavedResolution = closeAfterResolution;
+ SetSettingsDirty(true, animate: false);
+ AnimateUnsavedPrompt();
+ }
+
+ private void AnimateUnsavedPrompt()
+ {
+ WindowShakeTransform.BeginAnimation(TranslateTransform.XProperty, null);
+ var shake = new DoubleAnimationUsingKeyFrames
+ {
+ Duration = TimeSpan.FromMilliseconds(360),
+ FillBehavior = FillBehavior.Stop,
+ };
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(-8, KeyTime.FromPercent(0.12)));
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(7, KeyTime.FromPercent(0.26)));
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(-6, KeyTime.FromPercent(0.40)));
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(5, KeyTime.FromPercent(0.54)));
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(-3, KeyTime.FromPercent(0.68)));
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(3, KeyTime.FromPercent(0.82)));
+ shake.KeyFrames.Add(new LinearDoubleKeyFrame(0, KeyTime.FromPercent(1)));
+ WindowShakeTransform.BeginAnimation(TranslateTransform.XProperty, shake);
+
+ UnsavedChangesBar.BeginAnimation(
+ OpacityProperty,
+ new DoubleAnimation(0.58, 1, TimeSpan.FromMilliseconds(190))
+ {
+ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut },
+ });
+ }
+
+ private void DiscardSettings_Click(object sender, RoutedEventArgs e)
+ {
+ bool closeWindow = _closeAfterUnsavedResolution;
+ CancelHotkeyCapture();
+ LoadSettingsControls();
+ SetSettingsDirty(false, animate: false);
+ _closeAfterUnsavedResolution = false;
+ if (closeWindow)
+ {
+ _allowClose = true;
+ Close();
+ }
+ else
+ {
+ ShowDashboard();
+ }
+ }
+
+ private void Window_Closing(object? sender, CancelEventArgs e)
+ {
+ if (_allowClose ||
+ Application.Current.Dispatcher.HasShutdownStarted ||
+ SettingsPanel.Visibility != Visibility.Visible ||
+ !_settingsDirty)
+ {
+ return;
+ }
+
+ e.Cancel = true;
+ PromptForUnsavedChanges(closeAfterResolution: true);
+ }
+
private void LanguageList_MouseLeftButtonUp(
object sender,
MouseButtonEventArgs e)
@@ -637,6 +967,7 @@ private void GitHub_Click(object sender, RoutedEventArgs e)
UseShellExecute = true,
});
AnimatePress(GitHubButton);
+ AboutPopup.IsOpen = false;
}
catch (Exception exception)
{
@@ -654,6 +985,7 @@ private void ReportBug_Click(object sender, RoutedEventArgs e)
UseShellExecute = true,
});
AnimatePress(ReportBugButton);
+ AboutPopup.IsOpen = false;
}
catch (Exception exception)
{
@@ -671,6 +1003,7 @@ private void FeatureRequest_Click(object sender, RoutedEventArgs e)
UseShellExecute = true,
});
AnimatePress(FeatureRequestButton);
+ AboutPopup.IsOpen = false;
}
catch (Exception exception)
{
@@ -882,19 +1215,24 @@ private async Task LoadAutostartStateAsync()
try
{
AutostartBox.IsChecked = enabled;
+ _savedAutostartEnabled = enabled;
}
finally
{
_updatingUi = false;
}
+ RefreshSettingsDirtyState();
}
private void ShowSettings()
{
LoadSettingsControls();
+ _closeAfterUnsavedResolution = false;
+ SetSettingsDirty(false, animate: false);
DashboardPanel.Visibility = Visibility.Collapsed;
SettingsPanel.Visibility = Visibility.Visible;
SettingsButton.Visibility = Visibility.Collapsed;
+ CancelSettingsButton.Visibility = Visibility.Visible;
DoneButton.Visibility = Visibility.Visible;
SetWindowHeight(Math.Min(720, SystemParameters.WorkArea.Height - 64));
AnimateView(SettingsPanel);
@@ -931,9 +1269,12 @@ private void OpenSettingsAt(FrameworkElement target, Control? focusTarget)
private void ShowDashboard()
{
CancelHotkeyCapture();
+ _closeAfterUnsavedResolution = false;
+ SetSettingsDirty(false, animate: false);
SettingsPanel.Visibility = Visibility.Collapsed;
DashboardPanel.Visibility = Visibility.Visible;
SettingsButton.Visibility = Visibility.Visible;
+ CancelSettingsButton.Visibility = Visibility.Collapsed;
DoneButton.Visibility = Visibility.Collapsed;
SetWindowHeight(DashboardHeight);
UpdateRuntimeState(_runtimeActive);
@@ -1144,6 +1485,7 @@ private void BrowseOutput_Click(object sender, RoutedEventArgs e)
_outputDirectory = dialog.FolderName;
OutputDirText.Text = _outputDirectory;
_ = RefreshDiskAsync();
+ RefreshSettingsDirtyState();
}
private void HotkeyCapture_Click(object sender, RoutedEventArgs e)
@@ -1158,6 +1500,13 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (_capturingHotkeyButton is null)
{
+ if (e.Key == Key.Escape && AboutPopup.IsOpen)
+ {
+ AboutPopup.IsOpen = false;
+ AboutButton.Focus();
+ e.Handled = true;
+ return;
+ }
if (e.Key == Key.Escape && LanguagePopup.IsOpen)
{
LanguagePopup.IsOpen = false;
@@ -1173,8 +1522,10 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
}
if (e.Key == Key.Escape && SettingsPanel.Visibility == Visibility.Visible)
{
- LoadSettingsControls();
- ShowDashboard();
+ if (_settingsDirty)
+ PromptForUnsavedChanges(closeAfterResolution: false);
+ else
+ ShowDashboard();
e.Handled = true;
}
return;
@@ -1214,6 +1565,7 @@ private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
_pendingToggleHotkey = hotkey;
_capturingHotkeyButton.Content = hotkey;
_capturingHotkeyButton = null;
+ RefreshSettingsDirtyState();
}
private void CancelHotkeyCapture()
@@ -1263,6 +1615,7 @@ private async void Apply_Click(object sender, RoutedEventArgs e)
GetSelectedTag(AudioTrackModeBox, "mixed") == "separate";
Config candidate = _config.Clone();
candidate.ReplayEnabled = SettingsReplayToggle.IsChecked == true;
+ candidate.WarnWhenGameStartsWithReplayOff = WarnGameOffBox.IsChecked == true;
candidate.ShowRecordingIndicator =
RecordingIndicatorBox.IsChecked == true;
candidate.RecordingIndicatorPosition = GetSelectedRadioTag(
@@ -1312,8 +1665,18 @@ private async void Apply_Click(object sender, RoutedEventArgs e)
return;
Applied = true;
+ _savedAutostartEnabled = AutostartBox.IsChecked == true;
+ SetSettingsDirty(false, animate: false);
_ = RefreshDiskAsync();
- ShowDashboard();
+ if (_closeAfterUnsavedResolution)
+ {
+ _allowClose = true;
+ Close();
+ }
+ else
+ {
+ ShowDashboard();
+ }
}
catch (Exception exception)
{
@@ -1338,6 +1701,7 @@ private bool TryBeginAction()
MicSourceChip.IsEnabled = false;
SettingsReplayToggle.IsEnabled = false;
DoneButton.IsEnabled = false;
+ CancelSettingsButton.IsEnabled = false;
return true;
}
@@ -1349,6 +1713,7 @@ private void EndAction()
MicSourceChip.IsEnabled = true;
SettingsReplayToggle.IsEnabled = true;
DoneButton.IsEnabled = true;
+ CancelSettingsButton.IsEnabled = true;
}
private async Task RunUiActionAsync(Func action)
@@ -1559,6 +1924,26 @@ private void TrimReplay_Click(object sender, RoutedEventArgs e)
editor.ShowDialog();
}
+ private void PlayReplay_Click(object sender, RoutedEventArgs e)
+ {
+ if (((FrameworkElement)sender).DataContext is not ReplayClipItem item || !item.CanTrim)
+ return;
+ var player = new ClipEditorWindow(
+ _replayLibrary,
+ _outputDirectory,
+ item.Clip,
+ savedPath =>
+ {
+ Log.Write($"Trimmed replay saved: {savedPath}");
+ _ = RefreshReplayLibraryAsync();
+ },
+ ClipWindowMode.Preview)
+ {
+ Owner = this,
+ };
+ player.ShowDialog();
+ }
+
private void RequestDeleteReplay_Click(object sender, RoutedEventArgs e)
{
if (((FrameworkElement)sender).DataContext is not ReplayClipItem item)
diff --git a/src/Captail/Themes/Theme.xaml b/src/Captail/Themes/Theme.xaml
index e3be0f2..011dbb3 100644
--- a/src/Captail/Themes/Theme.xaml
+++ b/src/Captail/Themes/Theme.xaml
@@ -88,6 +88,7 @@
M3 8h5V3 M21 8h-5V3 M3 16h5v5 M21 16h-5v5M12 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
+ M12 10v7 M12 7h.01 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM12 8v5 M12 17h.01 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM12 7v10 M7 12h10 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z
@@ -441,6 +442,73 @@
+
+
+
+