diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..20fa392
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,111 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**Elapsed** is a cross-platform native timelapse app for Hack Club's Lapse platform. It includes a multi-platform desktop/mobile UI (Uno Platform + .NET 10), a CLI tool, and supporting libraries.
+
+- .NET 10 SDK, C# 14, file-scoped namespaces, implicit usings, nullable enabled
+- Solution file: Elapsed.slnx
+
+## Solution Structure
+
+### Platform Projects (Executables)
+
+- **Riverside.Elapsed.App** (`src/platforms/Riverside.Elapsed.App/`) — Uno Platform app targeting net10.0-desktop, net10.0-android, net10.0-ios, net10.0-browserwasm, net10.0-windows10.0.26100.0. MVVM architecture with Views, ViewModels, Models, Services. Startup project for debugging.
+- **Riverside.Elapsed.CommandLine** (`src/platforms/Riverside.Elapsed.CommandLine/`) — .NET console app (net10.0) using System.CommandLine. Exposes the full Lapse API with JSON output. Installable as a dotnet tool.
+
+### Core Libraries (Shared)
+
+- **Riverside.Elapsed** (`src/core/Riverside.Elapsed/`) — .NET Standard 2.1. Lapse API projection auto-generated by Kiota from the OpenAPI spec at `https://api.lapse.hackclub.com/openapi.json`. Downloads the spec at build time.
+- **Riverside.MediaRecording** (`src/core/Riverside.MediaRecording/`) — .NET 10 cross-platform media recording (camera, screen, microphone). Platform-specific code in `Windows/` subdirectory.
+- **Riverside.ResumableUploads** (`src/core/Riverside.ResumableUploads/`) — .NET Standard 2.1 TUS protocol client for resumable uploads.
+
+## Build Commands
+
+```bash
+# Restore workloads (required first time)
+dotnet workload restore src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj
+
+# Build desktop target
+dotnet build src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-desktop
+
+# Build Windows target
+dotnet build src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-windows10.0.26100.0
+
+# Build WebAssembly
+dotnet build src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-browserwasm
+
+# Build CLI
+dotnet build src/platforms/Riverside.Elapsed.CommandLine/Riverside.Elapsed.CommandLine.csproj
+
+# Publish (any target, add -c Release for release builds)
+dotnet publish src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj -f net10.0-desktop -c Release
+```
+
+VS Code tasks are preconfigured in `.vscode/tasks.json`: build-wasm, publish-wasm, build-desktop, publish-desktop, build-windows, publish-windows.
+
+Build artifacts go to `bin/{ProjectName}/AnyCPU/{Configuration}/{TargetFramework}/`. Web builds use a relative `wwwroot/` path due to an Uno Wasm Bootstrap SDK bug.
+
+## Code Style
+
+- **Tabs** for indentation (not spaces)
+- **File-scoped namespaces** (enforced as warning in .editorconfig)
+- **American English** in code, **British English** in documentation and comments
+- PascalCase for types/methods/properties, camelCase for locals, `I` prefix for interfaces
+- See `.editorconfig` for full Roslyn analyzer rules
+
+## Architecture
+
+### App (MVVM with Uno Extensions)
+
+- Services registered via `IHost` dependency injection in `App.xaml.cs`
+- Navigation via `INavigator` (Uno.Extensions.Navigation)
+- ViewModels use `ObservableObject` and `RelayCommand` (CommunityToolkit.Mvvm)
+- Configuration loaded from `appsettings.json` into `AppConfig`
+- UnoFeatures enabled: Hosting, Toolkit, Mvvm, Configuration, HttpKiota, Serialization, Localization, Authentication, Navigation, ThemeService, SkiaRenderer, Lottie, Logging/Serilog
+
+Key service areas under `Services/`:
+- `Auth/` — OAuth token management via ILapseAuthService
+- `Api/` — Kiota-generated API service interfaces
+- `Storage/` — Local app data persistence
+- `Recording/` — Media recording (desktop/Windows only, gated by `HAS_MEDIA_RECORDING` define)
+- `Drafts/` — Draft timelapse management
+- `Build/` — Build metadata (version, timestamp generated at compile time via MSBuild target)
+
+### Platform-Specific Code
+
+Platform code lives in `Platforms/{Android,iOS,Desktop,WebAssembly}/` within the App project. Uno's single-project structure uses conditional compilation. The `HAS_MEDIA_RECORDING` define is set for desktop and Windows TFMs only.
+
+### Lapse API Projection
+
+The `Riverside.Elapsed` library uses the `Riverside.CompilerPlatform.CSharp.Features.Kiota` source generator. At build time, the OpenAPI spec is downloaded and C# client code is generated. The generated client uses Kiota's `IRequestAdapter` pattern. Do not manually edit generated API code.
+
+## Versioning & Releases
+
+Version is controlled in `eng/CurrentVersion.props`. Format: `{MAJOR}.{MINOR}.{YYMMDD}[-{LEVEL}{BETA}]` (e.g., `0.4.260531`, `2.1.260220-preview2`).
+
+- Debug builds automatically get a `-preview1` suffix
+- Release levels: `alpha`, `beta`, `preview`, `rc`, `final` (final hides the suffix)
+- Changing `eng/CurrentVersion.props` on main triggers the CD pipeline, which publishes to GitHub Releases, NuGet, and Vercel (web)
+
+## CI/CD
+
+GitHub Actions in `.github/workflows/ci.yml`:
+- Triggers on push to main, PRs, and manual dispatch
+- Matrix builds across all target frameworks in Debug and Release
+- XAML formatting validation with XamlStyler
+- Runs on Windows Server 2025 with VS 2026
+
+## Testing
+
+No test projects exist yet. The CI pipeline has unit test jobs commented out.
+
+## Important Notes
+
+- **Native AOT & Trimming**: Desktop/Windows release builds use Native AOT. The CLI uses reflection and is NOT AOT-safe — it ships separately.
+- **Kiota regeneration**: The API library auto-regenerates on build if the OpenAPI spec changes. Don't edit generated code.
+- **HAS_MEDIA_RECORDING**: Conditional define only set for `-desktop` and `-windows` TFMs. Guard media recording code behind `#if HAS_MEDIA_RECORDING`.
+- **Localization**: String resources in `Strings/` directory, multiple languages supported.
+- **Design reference**: Figma mockups at https://www.figma.com/design/dUoOj27yGtoY3Y6HRKYJnF/Elapsed--WinUI-3-
diff --git a/README.md b/README.md
index 355a61a..53bdd3a 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,29 @@ cd Elapsed
- Navigate to the debug pane in the left-side activity bar
- Run the appropriate debug profile for the platform you want to build for (or press F5 anywhere in VS Code)
+##### CLI
+
+Building Elapsed with the MSBuild CLI is a great way to quickly test whether the app builds, as the MSBuild CLI does not rely on GUI-heavy apps such as Visual Studio.
+It is recommended to use the .NET Framework version of MSBuild as it works best for the tech stack that the Elapsed project consumes.
+
+- Restore the project dependencies via NuGet
+
+```bash
+msbuild -t:Restore -p:Configuration=Release
+```
+
+- Build/publish the project of your choice, such as `Riverside.Elapsed.App` for the main app project.
+
+You must specify a `TargetFramework` property when building the app project, such as `net10.0-desktop`, `net10.0-windows`, or `net10.0-browserwasm`.
+When building the Windows head of the app project, it is recommended to debug via an IDE such as Visual Studio as an IDE will provide a better experience for building WinUI apps.
+It is also recommended to make use of the `Publish` task when building the WebAssembly/WinAppSDK/Skia heads as this will ensure the output is production ready.
+
+```bash
+msbuild src\platforms\Riverside.Elapsed.App -t:Publish -p:Configuration=Release -p:TargetFramework=net10.0-desktop
+```
+
+The build output can always be found in the `bin` folder of the project root directory.
+
+
+
+
+
+
+
+
diff --git a/src/platforms/Riverside.Elapsed.App/App.xaml.cs b/src/platforms/Riverside.Elapsed.App/App.xaml.cs
index 76be3c7..a5ac0b7 100644
--- a/src/platforms/Riverside.Elapsed.App/App.xaml.cs
+++ b/src/platforms/Riverside.Elapsed.App/App.xaml.cs
@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
+using Riverside.Elapsed.App.Services.Recording;
+using Riverside.Elapsed.App.Services.Upload;
using Riverside.Elapsed.App.ViewModels;
using Uno.Resizetizer;
@@ -11,8 +13,7 @@ public App()
InitializeComponent();
}
- protected Window? MainWindow { get; private set; }
- protected IHost? Host { get; private set; }
+ public static Window? MainWindow { get; private set; }
[SuppressMessage("Trimming", "IL2026", Justification = "Uno app builder usage is trim-safe for configured features.")]
protected override async void OnLaunched(LaunchActivatedEventArgs args)
@@ -32,10 +33,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
.UseSerilog(consoleLoggingEnabled: true, fileLoggingEnabled: true)
.UseConfiguration(configure: (IConfigBuilder configBuilder) => configBuilder
.EmbeddedSource()
- .Section())
+ // .Section()
+ )
.UseLocalization()
.ConfigureServices((context, services) => { })
- .UseNavigation(RegisterRoutes)
+ //.UseNavigation(RegisterRoutes)
.UseSerialization(serialization => serialization.AddSingleton(Constants.SerializerOptions))
);
@@ -46,20 +48,42 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args)
#endif
MainWindow.SetWindowIcon();
- Host = await builder.NavigateAsync(initialNavigate: async (services, navigator) =>
+ ICaptureSourceProvider sourceProvider;
+
+ if (OperatingSystem.IsWindows())
+ sourceProvider = new WindowsCaptureSourceProvider();
+ else if (OperatingSystem.IsMacOS())
+ sourceProvider = new MacCaptureSourceProvider();
+ else if (OperatingSystem.IsLinux())
+ sourceProvider = new LinuxCaptureSourceProvider();
+ else
+ sourceProvider = new NoOpCaptureSourceProvider();
+
+ IRecordingFacade recording = new TimelapseRecordingFacade(sourceProvider);
+ /*
+ IRecordingFacade recording = new NoOpRecordingFacade();
+ ICaptureSourceProvider sourceProvider = new NoOpCaptureSourceProvider();
+ */
+
+ var lapse = new LapseService();
+
+ MainWindow.Content = new RecordingPage
{
//var authService = services.GetRequiredService();
//await authService.TryRestoreSessionAsync();
- await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested);
- });
+ //await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested);
+ DataContext = new RecordingViewModel(recording, sourceProvider, lapse)
+ };
+ MainWindow.Activate();
}
+ /*
private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes)
{
views.Register(
new ViewMap(),
//new ViewMap(),
- new ViewMap()
+ //new ViewMap()
//new ViewMap(),
//new ViewMap(),
//new ViewMap()
@@ -72,12 +96,12 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes)
Nested:
[
//new RouteMap("Login", View: views.FindByViewModel()),
- new RouteMap("Main", View: views.FindByViewModel(), IsDefault: true),
+ //new RouteMap("Main", View: views.FindByViewModel(), IsDefault: true),
//new RouteMap("Video", View: views.FindByViewModel()),
//new RouteMap("Recording", View: views.FindByViewModel()),
//new RouteMap("UserProfile", View: views.FindByViewModel()),
]
)
);
- }
+ } */
}
diff --git a/src/platforms/Riverside.Elapsed.App/Assets/Icons/brand-icon.png b/src/platforms/Riverside.Elapsed.App/Assets/Icons/brand-icon.png
new file mode 100644
index 0000000..c48a17b
Binary files /dev/null and b/src/platforms/Riverside.Elapsed.App/Assets/Icons/brand-icon.png differ
diff --git a/src/platforms/Riverside.Elapsed.App/Converters/BoolToPausedRecordingLabelConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/BoolToPausedRecordingLabelConverter.cs
new file mode 100644
index 0000000..6a897fe
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Converters/BoolToPausedRecordingLabelConverter.cs
@@ -0,0 +1,12 @@
+using CommunityToolkit.WinUI.Converters;
+
+namespace Riverside.Elapsed.App.Converters;
+
+public sealed partial class BoolToPausedRecordingLabelConverter : BoolToObjectConverter
+{
+ public BoolToPausedRecordingLabelConverter()
+ {
+ TrueValue = "Paused";
+ FalseValue = "Recording";
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Converters/BoolToToggleColorConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/BoolToToggleColorConverter.cs
new file mode 100644
index 0000000..e188a74
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Converters/BoolToToggleColorConverter.cs
@@ -0,0 +1,13 @@
+using CommunityToolkit.WinUI.Converters;
+using Microsoft.UI;
+
+namespace Riverside.Elapsed.App.Converters;
+
+public sealed class BoolToToggleColorConverter : BoolToObjectConverter
+{
+ public BoolToToggleColorConverter()
+ {
+ TrueValue = new SolidColorBrush(Colors.DodgerBlue); ;
+ FalseValue = new SolidColorBrush(Colors.Coral);
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Converters/NullToCollapsedConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/NullToCollapsedConverter.cs
new file mode 100644
index 0000000..df2ebaf
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Converters/NullToCollapsedConverter.cs
@@ -0,0 +1,19 @@
+using Microsoft.UI.Xaml.Data;
+
+namespace Riverside.Elapsed.App.Converters;
+
+public sealed class NullToCollapsedConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ bool isNull = value is null or "";
+ if (parameter is "Invert")
+ isNull = !isNull;
+ return isNull ? Visibility.Collapsed : Visibility.Visible;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotSupportedException();
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminExport.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminExport.cs
index d039536..718d4fa 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminExport.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminExport.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class AdminExport
{
public object Data { get; set; } = new();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListPage.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListPage.cs
index 7d4b1a4..3d6c436 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListPage.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListPage.cs
@@ -1,3 +1,4 @@
+#if false
using System.Text.Json;
namespace Riverside.Elapsed.App.Models.Admin;
@@ -10,3 +11,5 @@ public sealed class AdminListPage
public long Page { get; set; }
public long PageSize { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListResponse.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListResponse.cs
index 7b1b0b5..f9d4c58 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListResponse.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminListResponse.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class AdminListResponse
@@ -8,3 +9,5 @@ public sealed class AdminListResponse
public long Page { get; set; }
public long PageSize { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResult.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResult.cs
index 31e6b5a..65dac6a 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResult.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResult.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class AdminSearchResult
@@ -6,3 +7,5 @@ public sealed class AdminSearchResult
public string Id { get; set; } = string.Empty;
public string DisplayText { get; set; } = string.Empty;
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResults.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResults.cs
index 9bfd960..ba13d9e 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResults.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminSearchResults.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class AdminSearchResults
{
public IReadOnlyList Results { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminStats.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminStats.cs
index 3bc3d9e..7377a3d 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminStats.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminStats.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class AdminStats
@@ -6,3 +7,5 @@ public sealed class AdminStats
public long TotalProjects { get; set; }
public long TotalUsers { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminUpdateResult.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminUpdateResult.cs
index 5dee00e..8483903 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminUpdateResult.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/AdminUpdateResult.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class AdminUpdateResult
@@ -5,3 +6,5 @@ public sealed class AdminUpdateResult
public EntityType Entity { get; set; }
public object Row { get; set; } = new();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/EntityType.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/EntityType.cs
index cf04a2f..3af14f6 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/EntityType.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/EntityType.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public enum EntityType
@@ -8,3 +9,5 @@ public enum EntityType
DraftTimelapse,
LegacyTimelapse,
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyList.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyList.cs
index 432045e..d3f71a6 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyList.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyList.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class ProgramKeyList
{
public IReadOnlyList Keys { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyMetadata.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyMetadata.cs
index 10b63cc..0bfebd7 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyMetadata.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeyMetadata.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class ProgramKeyMetadata
@@ -12,3 +13,5 @@ public sealed class ProgramKeyMetadata
public DateTimeOffset? RevokedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeySecret.cs b/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeySecret.cs
index 54cebb8..549ffe4 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeySecret.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Admin/ProgramKeySecret.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Admin;
public sealed class ProgramKeySecret
@@ -5,3 +6,5 @@ public sealed class ProgramKeySecret
public ProgramKeyMetadata Key { get; set; } = new();
public string RawKey { get; set; } = string.Empty;
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/AppConfig.cs b/src/platforms/Riverside.Elapsed.App/Models/AppConfig.cs
index 51ebef0..ce1e405 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/AppConfig.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/AppConfig.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models;
public record AppConfig
{
public string? Environment { get; init; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Auth/OAuthToken.cs b/src/platforms/Riverside.Elapsed.App/Models/Auth/OAuthToken.cs
index 3dda5cf..3e6117a 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Auth/OAuthToken.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Auth/OAuthToken.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Auth;
public sealed class OAuthToken
@@ -8,3 +9,5 @@ public sealed class OAuthToken
public string Scope { get; set; } = string.Empty;
public string? RefreshToken { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/DeveloperApp.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/DeveloperApp.cs
index 15d82a5..0d1ac16 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/DeveloperApp.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/DeveloperApp.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public sealed class DeveloperApp
@@ -14,3 +15,5 @@ public sealed class DeveloperApp
public DateTimeOffset CreatedAt { get; set; }
public User.User? CreatedBy { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppList.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppList.cs
index c7d07c7..77ecb46 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppList.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppList.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public sealed class OAuthAppList
{
public IReadOnlyList Apps { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppSecret.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppSecret.cs
index 45998d8..b5f6726 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppSecret.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthAppSecret.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public sealed class OAuthAppSecret
@@ -5,3 +6,5 @@ public sealed class OAuthAppSecret
public DeveloperApp App { get; set; } = new();
public string ClientSecret { get; set; } = string.Empty;
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrant.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrant.cs
index 363e344..552cf6d 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrant.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrant.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public sealed class OAuthGrant
@@ -9,3 +10,5 @@ public sealed class OAuthGrant
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? LastUsedAt { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantList.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantList.cs
index ac0d4cb..2a774f1 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantList.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantList.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public sealed class OAuthGrantList
{
public IReadOnlyList Grants { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantListPage.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantListPage.cs
index 1490128..31fc20a 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantListPage.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/OAuthGrantListPage.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public sealed class OAuthGrantListPage
{
public IReadOnlyList Grants { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Developer/TrustLevel.cs b/src/platforms/Riverside.Elapsed.App/Models/Developer/TrustLevel.cs
index ca17445..4d96320 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Developer/TrustLevel.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Developer/TrustLevel.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Developer;
public enum TrustLevel
@@ -5,3 +6,5 @@ public enum TrustLevel
Untrusted,
Trusted,
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Global/ActiveUsers.cs b/src/platforms/Riverside.Elapsed.App/Models/Global/ActiveUsers.cs
index 910418c..b5abf26 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Global/ActiveUsers.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Global/ActiveUsers.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Global;
public sealed class ActiveUsers
{
public double Count { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Global/LeaderboardEntry.cs b/src/platforms/Riverside.Elapsed.App/Models/Global/LeaderboardEntry.cs
index 214a90a..7fb7767 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Global/LeaderboardEntry.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Global/LeaderboardEntry.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Global;
public sealed class LeaderboardEntry
@@ -5,3 +6,5 @@ public sealed class LeaderboardEntry
public User.User User { get; set; } = new();
public double SecondsThisWeek { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProject.cs b/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProject.cs
index 3968a4f..243513c 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProject.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProject.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Hackatime;
public sealed class HackatimeProject
@@ -5,3 +6,5 @@ public sealed class HackatimeProject
public string Name { get; set; } = string.Empty;
public double TotalSeconds { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProjectTimelapses.cs b/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProjectTimelapses.cs
index 4b650e0..610e1c7 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProjectTimelapses.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Hackatime/HackatimeProjectTimelapses.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Hackatime;
public sealed class HackatimeProjectTimelapses
@@ -5,3 +6,5 @@ public sealed class HackatimeProjectTimelapses
public double Count { get; set; }
public IReadOnlyList Timelapses { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Primitives/IUploadable.cs b/src/platforms/Riverside.Elapsed.App/Models/Primitives/IUploadable.cs
index 78c236b..cdbb8cc 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Primitives/IUploadable.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Primitives/IUploadable.cs
@@ -1,3 +1,4 @@
+#if false
using Riverside.Elapsed.App.Models.Timelapses.Local;
namespace Riverside.Elapsed.App.Models.Primitives;
@@ -13,3 +14,5 @@ public interface IUploadable
string? UploadToken { get; init; }
TusUploadState Upload { get; init; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureDevice.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureDevice.cs
new file mode 100644
index 0000000..0dae2b8
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureDevice.cs
@@ -0,0 +1,3 @@
+namespace Riverside.Elapsed.App.Models.Recording;
+
+public sealed partial record CaptureDevice(string Id, string Name);
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSource.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSource.cs
new file mode 100644
index 0000000..934676e
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSource.cs
@@ -0,0 +1,81 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Microsoft.UI.Xaml.Media.Imaging;
+
+namespace Riverside.Elapsed.App.Models.Recording;
+
+public sealed class CaptureSource : INotifyPropertyChanged
+{
+ private string _name = "";
+ private string? _description;
+ private string? _resolution;
+ private Microsoft.UI.Xaml.Media.ImageSource? _thumbnail;
+ private Microsoft.UI.Xaml.Media.ImageSource? _icon;
+ private WriteableBitmap? _thumbnailBitmap;
+
+ public required string Id { get; init; }
+ public required CaptureSourceKind Kind { get; init; }
+
+ public required string Name
+ {
+ get => _name;
+ set => SetField(ref _name, value);
+ }
+
+ public string? Description
+ {
+ get => _description;
+ set => SetField(ref _description, value);
+ }
+
+ public string? Resolution
+ {
+ get => _resolution;
+ set => SetField(ref _resolution, value);
+ }
+
+ public Microsoft.UI.Xaml.Media.ImageSource? Thumbnail
+ {
+ get => _thumbnail;
+ set => SetField(ref _thumbnail, value);
+ }
+
+ public Microsoft.UI.Xaml.Media.ImageSource? Icon
+ {
+ get => _icon;
+ set => SetField(ref _icon, value);
+ }
+
+ public void BlitThumbnail(byte[] bgra, int width, int height, bool bottomUp = false)
+ {
+ if (_thumbnailBitmap is null || _thumbnailBitmap.PixelWidth != width || _thumbnailBitmap.PixelHeight != height)
+ {
+ _thumbnailBitmap = new WriteableBitmap(width, height);
+ Thumbnail = _thumbnailBitmap;
+ }
+
+ using var stream = _thumbnailBitmap.PixelBuffer.AsStream();
+ if (bottomUp)
+ {
+ int stride = width * 4;
+ for (int y = height - 1; y >= 0; y--)
+ stream.Write(bgra, y * stride, stride);
+ }
+ else
+ {
+ stream.Write(bgra, 0, width * height * 4);
+ }
+ _thumbnailBitmap.Invalidate();
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ private void SetField(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return;
+
+ field = value;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs
new file mode 100644
index 0000000..4fd1117
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs
@@ -0,0 +1,8 @@
+namespace Riverside.Elapsed.App.Models.Recording;
+
+public enum CaptureSourceKind
+{
+ Screen,
+ Window,
+ Camera,
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/CapturedFrame.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/CapturedFrame.cs
new file mode 100644
index 0000000..ea81649
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CapturedFrame.cs
@@ -0,0 +1,3 @@
+namespace Riverside.Elapsed.App.Models.Recording;
+
+public sealed record CapturedFrame(byte[] Pixels, int Width, int Height, bool IsBottomUp = false);
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs
new file mode 100644
index 0000000..425e31d
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs
@@ -0,0 +1,11 @@
+namespace Riverside.Elapsed.App.Models.Recording;
+
+public enum RecordingPhase
+{
+ Setup,
+ Active,
+ Paused,
+ Encoding,
+ Uploading,
+ Publishing,
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Comment.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Comment.cs
index 1d74b17..5cd03e6 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Comment.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Comment.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses;
public sealed class Comment
@@ -7,3 +8,5 @@ public sealed class Comment
public User.User Author { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/CursorPage{T}.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/CursorPage{T}.cs
index 715af57..24a99fb 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/CursorPage{T}.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/CursorPage{T}.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses;
public class CursorPage // infinite scroll
@@ -5,3 +6,5 @@ public class CursorPage // infinite scroll
public IReadOnlyList Items { get; set; } = Array.Empty();
public string? NextCursor { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftEdit.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftEdit.cs
index e4eee4d..bf0405b 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftEdit.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftEdit.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses;
public sealed class DraftEdit
@@ -6,3 +7,5 @@ public sealed class DraftEdit
public double EndSeconds { get; set; }
public EditKind Kind { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftTimelapse.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftTimelapse.cs
index e694c89..4b39a97 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftTimelapse.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/DraftTimelapse.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses;
public sealed class DraftTimelapse
@@ -14,3 +15,5 @@ public sealed class DraftTimelapse
public IReadOnlyList EditList { get; set; } = Array.Empty();
public string? AssociatedTimelapseId { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/EditKind.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/EditKind.cs
index 17684b9..214377b 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/EditKind.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/EditKind.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses;
public enum EditKind
{
Cut,
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/DraftPipelineState.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/DraftPipelineState.cs
index ece9bda..cd4c49a 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/DraftPipelineState.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/DraftPipelineState.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
public class DraftPipelineState
@@ -18,3 +19,5 @@ public enum Phase
public double Progress { get; init; }
public string? LastError { get; init; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraft.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraft.cs
index e46cb5e..b8bc869 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraft.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraft.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
public sealed record LocalDraft
@@ -37,3 +38,5 @@ public static LocalDraft Create(Guid deviceId, DateTimeOffset now)
};
}
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndex.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndex.cs
index 82f8e54..3674f33 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndex.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndex.cs
@@ -1,6 +1,9 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
public sealed record LocalDraftIndex
{
public IReadOnlyList Drafts { get; init; } = [];
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndexItem.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndexItem.cs
index cf735a0..fd35aa5 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndexItem.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalDraftIndexItem.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
public sealed record LocalDraftIndexItem
@@ -9,3 +10,5 @@ public sealed record LocalDraftIndexItem
public bool HasRemoteDraft { get; init; }
public string? RemoteDraftTimelapseId { get; init; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalSession.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalSession.cs
index 5b4f06f..dc64965 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalSession.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalSession.cs
@@ -1,3 +1,4 @@
+#if false
using Riverside.Elapsed.App.Models.Primitives;
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
@@ -12,3 +13,5 @@ public sealed record LocalSession : IUploadable
public string? UploadToken { get; init; }
public TusUploadState Upload { get; init; } = new();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalThumbnail.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalThumbnail.cs
index f927bf3..70b842f 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalThumbnail.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/LocalThumbnail.cs
@@ -1,3 +1,4 @@
+#if false
using System;
using System.Collections.Generic;
using System.Text;
@@ -13,3 +14,5 @@ public sealed record LocalThumbnail : IUploadable
public string? UploadToken { get; init; }
public TusUploadState Upload { get; init; } = new();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/RemoteDraftSync.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/RemoteDraftSync.cs
index 1159688..3274e57 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/RemoteDraftSync.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/RemoteDraftSync.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
public class RemoteDraftSync
@@ -5,3 +6,5 @@ public class RemoteDraftSync
public string DraftTimelapseId { get; init; } = string.Empty;
public string IvHex { get; init; } = string.Empty; // draft IV as hex string (not byte[] because json serialiser will get confused)
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/TusUploadState.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/TusUploadState.cs
index 3daafc3..0edf418 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/TusUploadState.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Local/TusUploadState.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses.Local;
public class TusUploadState
@@ -12,3 +13,5 @@ public class TusUploadState
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Timelapse.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Timelapse.cs
index 7cffc7b..388abbc 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Timelapse.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Timelapse.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.Timelapses;
public sealed class Timelapse
@@ -15,3 +16,5 @@ public sealed class Timelapse
public string? HackatimeProject { get; set; }
public string? SourceDraftId { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Visibility.cs b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Visibility.cs
index 67f2f7b..bf7be27 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Visibility.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/Timelapses/Visibility.cs
@@ -1,3 +1,4 @@
+#if false
using System;
using System.Collections.Generic;
using System.Text;
@@ -10,3 +11,5 @@ public enum Visibility
Public,
FailedProcessing,
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/Device.cs b/src/platforms/Riverside.Elapsed.App/Models/User/Device.cs
index 463f04e..3e82708 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/Device.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/Device.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.User;
public sealed class Device
@@ -5,3 +6,5 @@ public sealed class Device
public Guid DeviceId { get; set; }
public string Name { get; set; } = string.Empty;
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/Local/DeviceKey.cs b/src/platforms/Riverside.Elapsed.App/Models/User/Local/DeviceKey.cs
index cb1a162..bb96333 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/Local/DeviceKey.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/Local/DeviceKey.cs
@@ -1,4 +1,7 @@
+#if false
namespace Riverside.Elapsed.App.Models.User.Local;
[ImplicitKeys(IsEnabled = false)]
public record DeviceKey(Guid DeviceId, byte[] Key, DateTimeOffset CreatedAt);
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayRequest.cs b/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayRequest.cs
index 3296c84..7e86d47 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayRequest.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayRequest.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.User.Local;
public sealed class KeyRelayRequest
@@ -5,3 +6,5 @@ public sealed class KeyRelayRequest
public Guid ExchangeId { get; set; }
public Guid CallingDeviceId { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayResult.cs b/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayResult.cs
index d9eeb88..a83287d 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayResult.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/Local/KeyRelayResult.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.User.Local;
public sealed class KeyRelayResult
@@ -5,3 +6,5 @@ public sealed class KeyRelayResult
public Guid DeviceId { get; set; }
public byte[] DeviceKey { get; set; } = Array.Empty();
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/Myself.cs b/src/platforms/Riverside.Elapsed.App/Models/User/Myself.cs
index 386334e..7324a2f 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/Myself.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/Myself.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.User;
public sealed class Myself : User
@@ -6,3 +7,5 @@ public sealed class Myself : User
public bool NeedsReauth { get; set; }
public PermissionLevel PermissionLevel { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/PermissionLevel.cs b/src/platforms/Riverside.Elapsed.App/Models/User/PermissionLevel.cs
index 1f3f747..dcaa4bb 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/PermissionLevel.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/PermissionLevel.cs
@@ -1,3 +1,4 @@
+#if false
using System;
using System.Collections.Generic;
using System.Text;
@@ -10,3 +11,5 @@ public enum PermissionLevel
Admin,
Root,
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/User.cs b/src/platforms/Riverside.Elapsed.App/Models/User/User.cs
index 7b58fc7..b7b6fb0 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/User.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/User.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.User;
public class User
@@ -50,3 +51,5 @@ public static User FromDetails(UserDetails details)
return details is not null ? FromDetails(details) : user;
}
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Models/User/UserDetails.cs b/src/platforms/Riverside.Elapsed.App/Models/User/UserDetails.cs
index de41f80..180364e 100644
--- a/src/platforms/Riverside.Elapsed.App/Models/User/UserDetails.cs
+++ b/src/platforms/Riverside.Elapsed.App/Models/User/UserDetails.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Models.User;
public sealed class UserDetails
@@ -12,3 +13,5 @@ public sealed class UserDetails
public string? HackatimeId { get; set; }
public string? SlackId { get; set; }
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj b/src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj
index 8b257f0..80b2159 100644
--- a/src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj
+++ b/src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj
@@ -31,11 +31,11 @@
+
-
@@ -49,6 +49,16 @@
+
+
+
+
+
+
+
+
+
+
$(DefineConstants);HAS_MEDIA_RECORDING
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs
index e4c55c7..23c616b 100644
--- a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs
+++ b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs
@@ -1,33 +1,36 @@
-using System.Globalization;
-
-namespace Riverside.Elapsed.App.Services.Build;
-
-public sealed class BuildInfo
-{
- public string DisplayVersion { get; init; } = string.Empty;
- public DateTimeOffset BuildTimestamp { get; init; }
- public string FullFooterText { get; init; } = string.Empty;
- public string WebFooterText { get; init; } = string.Empty;
-
- public BuildInfo()
- {
- var timestamp = DateTimeOffset.TryParse(
- Constants.BuildTimestampIso,
- CultureInfo.InvariantCulture,
- DateTimeStyles.RoundtripKind,
- out var parsed)
- ? parsed.ToLocalTime()
- : DateTimeOffset.Now;
-
- var timeText = timestamp.ToString("MMMM d, yyyy 'at' h:mm tt", CultureInfo.InvariantCulture).Replace("AM", "am", StringComparison.Ordinal).Replace("PM", "pm", StringComparison.Ordinal);
-
- var versionText = Constants.DisplayVersion;
- var full = $"A Hack Club production. Version {versionText} from {timeText}. Built with <3 by ascpixi and Lamparter.";
- var compact = $"A Hack Club production. Version {versionText}";
-
- DisplayVersion = versionText;
- BuildTimestamp = timestamp;
- FullFooterText = full;
- WebFooterText = compact;
- }
-}
+#if false
+using System.Globalization;
+
+namespace Riverside.Elapsed.App.Services.Build;
+
+public sealed class BuildInfo
+{
+ public string DisplayVersion { get; init; } = string.Empty;
+ public DateTimeOffset BuildTimestamp { get; init; }
+ public string FullFooterText { get; init; } = string.Empty;
+ public string WebFooterText { get; init; } = string.Empty;
+
+ public BuildInfo()
+ {
+ var timestamp = DateTimeOffset.TryParse(
+ Constants.BuildTimestampIso,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.RoundtripKind,
+ out var parsed)
+ ? parsed.ToLocalTime()
+ : DateTimeOffset.Now;
+
+ var timeText = timestamp.ToString("MMMM d, yyyy 'at' h:mm tt", CultureInfo.InvariantCulture).Replace("AM", "am", StringComparison.Ordinal).Replace("PM", "pm", StringComparison.Ordinal);
+
+ var versionText = Constants.DisplayVersion;
+ var full = $"A Hack Club production. Version {versionText} from {timeText}. Built with <3 by ascpixi and Lamparter.";
+ var compact = $"A Hack Club production. Version {versionText}";
+
+ DisplayVersion = versionText;
+ BuildTimestamp = timestamp;
+ FullFooterText = full;
+ WebFooterText = compact;
+ }
+}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/BmpEncoder.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/BmpEncoder.cs
new file mode 100644
index 0000000..bd6483e
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/BmpEncoder.cs
@@ -0,0 +1,25 @@
+namespace Riverside.Elapsed.App.Services.Recording;
+
+internal static class BmpEncoder
+{
+ public static byte[] Encode(byte[] bgraPixels, int width, int height, bool topDown = false)
+ {
+ int imageSize = width * height * 4;
+ int fileSize = 54 + imageSize;
+ var bmp = new byte[fileSize];
+
+ bmp[0] = (byte)'B';
+ bmp[1] = (byte)'M';
+ BitConverter.TryWriteBytes(bmp.AsSpan(2), fileSize);
+ BitConverter.TryWriteBytes(bmp.AsSpan(10), 54);
+ BitConverter.TryWriteBytes(bmp.AsSpan(14), 40);
+ BitConverter.TryWriteBytes(bmp.AsSpan(18), width);
+ BitConverter.TryWriteBytes(bmp.AsSpan(22), topDown ? -height : height);
+ BitConverter.TryWriteBytes(bmp.AsSpan(26), (short)1);
+ BitConverter.TryWriteBytes(bmp.AsSpan(28), (short)32);
+ BitConverter.TryWriteBytes(bmp.AsSpan(34), (uint)imageSize);
+
+ bgraPixels.AsSpan(0, imageSize).CopyTo(bmp.AsSpan(54));
+ return bmp;
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/FFmpegService.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/FFmpegService.cs
new file mode 100644
index 0000000..e73ab20
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/FFmpegService.cs
@@ -0,0 +1,319 @@
+using System.Diagnostics;
+using System.Text;
+using System.Text.RegularExpressions;
+using SkiaSharp;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+internal static partial class FFmpegService
+{
+ private static string? _cachedPath;
+
+ public static string GetBinaryPath()
+ {
+ if (_cachedPath is not null)
+ return _cachedPath;
+
+ var exeName = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
+
+ var appDir = AppContext.BaseDirectory;
+ var bundled = Path.Combine(appDir, exeName);
+ if (File.Exists(bundled))
+ return _cachedPath = bundled;
+
+ if (OperatingSystem.IsMacOS())
+ {
+ foreach (var p in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
+ if (File.Exists(p)) return _cachedPath = p;
+ }
+ else if (OperatingSystem.IsLinux())
+ {
+ if (File.Exists("/usr/bin/ffmpeg"))
+ return _cachedPath = "/usr/bin/ffmpeg";
+ }
+
+ var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? [];
+ foreach (var dir in pathDirs)
+ {
+ var candidate = Path.Combine(dir, exeName);
+ if (File.Exists(candidate))
+ return _cachedPath = candidate;
+ }
+
+ throw new InvalidOperationException(
+ "FFmpeg is required but was not found. " +
+ "Please install FFmpeg and ensure it is available on your system PATH, " +
+ "or place the ffmpeg binary next to the application executable.");
+ }
+
+ public static Task IsAvailableAsync()
+ {
+ try
+ {
+ GetBinaryPath();
+ return Task.FromResult(true);
+ }
+ catch (Exception)
+ {
+ return Task.FromResult(false);
+ }
+ }
+
+ public static async Task EncodeFramesToVideoAsync(
+ string framesDirectory,
+ string outputPath,
+ double frameRate = 24.0,
+ IProgress? progress = null,
+ CancellationToken ct = default)
+ {
+ var frameFiles = Directory.GetFiles(framesDirectory, "frame-*.jpg");
+ if (frameFiles.Length == 0)
+ throw new InvalidOperationException("No frames to encode.");
+
+ int totalFrames = frameFiles.Length;
+ var (maxW, maxH) = ScanMaxDimensions(frameFiles);
+
+ maxW = RoundUpEven(maxW);
+ maxH = RoundUpEven(maxH);
+
+ var ffmpeg = GetBinaryPath();
+ var inputPattern = Path.Combine(framesDirectory, "frame-%06d.jpg");
+
+ var args = $"-y -framerate {frameRate} -i \"{inputPattern}\" " +
+ $"-vf \"scale={maxW}:{maxH}:force_original_aspect_ratio=decrease,pad={maxW}:{maxH}:(ow-iw)/2:(oh-ih)/2:black\" " +
+ $"-c:v libx264 -preset fast -crf 18 -pix_fmt yuv420p -movflags +faststart \"{outputPath}\"";
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = ffmpeg,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg.");
+ using var reg = ct.Register(() => { try { proc.Kill(); } catch (Exception) { } });
+
+ var stderr = new StringBuilder();
+ proc.ErrorDataReceived += (_, e) =>
+ {
+ if (e.Data is null) return;
+ stderr.AppendLine(e.Data);
+
+ var match = FrameProgressRegex().Match(e.Data);
+ if (match.Success && int.TryParse(match.Groups[1].Value, out int frame))
+ progress?.Report(Math.Min(1.0, (double)frame / totalFrames));
+ };
+ proc.BeginErrorReadLine();
+
+ await proc.WaitForExitAsync(ct);
+
+ if (proc.ExitCode != 0)
+ throw new InvalidOperationException($"FFmpeg exited with code {proc.ExitCode}:\n{stderr}");
+
+ progress?.Report(1.0);
+ }
+
+ public static Process StartCameraCapture(
+ string deviceId,
+ string framesDirectory,
+ int fps = 1)
+ {
+ var ffmpeg = GetBinaryPath();
+ string inputFormat;
+ string inputDevice;
+
+ if (OperatingSystem.IsWindows())
+ {
+ inputFormat = "dshow";
+ inputDevice = $"video={deviceId}";
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ inputFormat = "avfoundation";
+ inputDevice = deviceId;
+ }
+ else
+ {
+ inputFormat = "v4l2";
+ inputDevice = deviceId;
+ }
+
+ var outputPattern = Path.Combine(framesDirectory, "frame-%06d.jpg");
+ var args = $"-f {inputFormat} -i \"{inputDevice}\" -r {fps} -q:v 2 \"{outputPattern}\"";
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = ffmpeg,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ return Process.Start(psi) ?? throw new InvalidOperationException("Failed to start FFmpeg camera capture.");
+ }
+
+ public static async Task GrabCameraFrameAsync(string deviceId, string outputPath, CancellationToken ct = default)
+ {
+ var ffmpeg = GetBinaryPath();
+ string inputFormat;
+ string inputDevice;
+
+ if (OperatingSystem.IsWindows())
+ {
+ inputFormat = "dshow";
+ inputDevice = $"video={deviceId}";
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ inputFormat = "avfoundation";
+ inputDevice = deviceId;
+ }
+ else
+ {
+ inputFormat = "v4l2";
+ inputDevice = deviceId;
+ }
+
+ var args = $"-f {inputFormat} -i \"{inputDevice}\" -vframes 1 -y \"{outputPath}\"";
+ var psi = new ProcessStartInfo
+ {
+ FileName = ffmpeg,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ CreateNoWindow = true,
+ };
+
+ using var proc = Process.Start(psi);
+ if (proc is null) return null;
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(TimeSpan.FromSeconds(10));
+ using var reg = cts.Token.Register(() => { try { proc.Kill(); } catch (Exception) { } });
+
+ await proc.WaitForExitAsync(cts.Token);
+ return proc.ExitCode == 0 && File.Exists(outputPath) ? outputPath : null;
+ }
+
+ public static async Task> EnumerateCamerasAsync(CancellationToken ct = default)
+ {
+ var ffmpeg = GetBinaryPath();
+ string inputFormat;
+
+ if (OperatingSystem.IsWindows())
+ inputFormat = "dshow";
+ else if (OperatingSystem.IsMacOS())
+ inputFormat = "avfoundation";
+ else
+ inputFormat = "v4l2";
+
+ var args = $"-f {inputFormat} -list_devices true -i dummy";
+ var psi = new ProcessStartInfo
+ {
+ FileName = ffmpeg,
+ Arguments = args,
+ UseShellExecute = false,
+ RedirectStandardError = true,
+ RedirectStandardOutput = true,
+ CreateNoWindow = true,
+ };
+
+ using var proc = Process.Start(psi);
+ if (proc is null) return [];
+
+ var stderr = await proc.StandardError.ReadToEndAsync(ct);
+ await proc.WaitForExitAsync(ct);
+
+ return ParseCameraDevices(stderr, inputFormat);
+ }
+
+ private static List<(string id, string name)> ParseCameraDevices(string stderr, string inputFormat)
+ {
+ List<(string, string)> results = [];
+
+ if (inputFormat == "dshow")
+ {
+ foreach (var line in stderr.Split('\n'))
+ {
+ if (!line.Contains("(video)")) continue;
+ var match = DshowDeviceRegex().Match(line);
+ if (match.Success)
+ {
+ var name = match.Groups[1].Value;
+ results.Add((name, name));
+ }
+ }
+ }
+ else if (inputFormat == "avfoundation")
+ {
+ bool inVideo = false;
+ foreach (var line in stderr.Split('\n'))
+ {
+ if (line.Contains("AVFoundation video devices"))
+ inVideo = true;
+ else if (line.Contains("AVFoundation audio devices"))
+ break;
+ else if (inVideo)
+ {
+ var match = AvfoundationDeviceRegex().Match(line);
+ if (match.Success)
+ {
+ var index = match.Groups[1].Value;
+ var name = match.Groups[2].Value;
+ results.Add((index, name));
+ }
+ }
+ }
+ }
+ else if (inputFormat == "v4l2")
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ var devPath = $"/dev/video{i}";
+ if (File.Exists(devPath))
+ results.Add((devPath, $"Camera {i} ({devPath})"));
+ }
+ }
+
+ return results;
+ }
+
+ private static (int maxW, int maxH) ScanMaxDimensions(string[] frameFiles)
+ {
+ int maxW = 0, maxH = 0;
+
+ var sample = frameFiles.Length <= 10
+ ? frameFiles
+ : new[] { frameFiles[0], frameFiles[frameFiles.Length / 2], frameFiles[^1] };
+
+ foreach (var file in sample)
+ {
+ using var codec = SKCodec.Create(file);
+ if (codec is null) continue;
+ maxW = Math.Max(maxW, codec.Info.Width);
+ maxH = Math.Max(maxH, codec.Info.Height);
+ }
+
+ if (maxW == 0 || maxH == 0)
+ {
+ maxW = 1920;
+ maxH = 1080;
+ }
+
+ return (maxW, maxH);
+ }
+
+ private static int RoundUpEven(int value) => value % 2 == 0 ? value : value + 1;
+
+ [GeneratedRegex(@"frame=\s*(\d+)")]
+ private static partial Regex FrameProgressRegex();
+
+ [GeneratedRegex(@"""(.+?)""")]
+ private static partial Regex DshowDeviceRegex();
+
+ [GeneratedRegex(@"\[(\d+)\]\s+(.+)")]
+ private static partial Regex AvfoundationDeviceRegex();
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingResult.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingResult.cs
new file mode 100644
index 0000000..6ffdbcb
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingResult.cs
@@ -0,0 +1,8 @@
+namespace Riverside.Elapsed.App.Services.Recording;
+
+///
+/// Describes the artefact produced by .
+///
+/// The local path of the captured media file, if any.
+/// The total active recording duration (excluding paused intervals).
+public sealed record FacadeRecordingResult(string? FilePath, TimeSpan Duration);
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingState.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingState.cs
new file mode 100644
index 0000000..f968cfd
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingState.cs
@@ -0,0 +1,18 @@
+namespace Riverside.Elapsed.App.Services.Recording;
+
+///
+/// Indicates the lifecycle state of an .
+///
+public enum FacadeRecordingState
+{
+ /// The facade is ready, no session is active.
+ Idle,
+ /// A recording session is currently capturing frames.
+ Recording,
+ /// A recording session is paused.
+ Paused,
+ /// A recording session was stopped; the output file is finalised.
+ Stopped,
+ /// Recording is unsupported on the current platform.
+ Unsupported,
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs
new file mode 100644
index 0000000..88e759d
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs
@@ -0,0 +1,12 @@
+using Riverside.Elapsed.App.Models.Recording;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+public interface ICaptureSourceProvider
+{
+ Task> GetSourcesAsync(CaptureSourceKind kind);
+ Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight);
+ Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight);
+ Task CaptureFrameAsync(CaptureSource source);
+ Task RefreshThumbnailAsync(CaptureSource source, int maxWidth, int maxHeight);
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/IRecordingFacade.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/IRecordingFacade.cs
new file mode 100644
index 0000000..b5ae1e7
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/IRecordingFacade.cs
@@ -0,0 +1,42 @@
+namespace Riverside.Elapsed.App.Services.Recording;
+
+///
+/// Cross-platform recording abstraction consumed by RecordingViewModel. Implementations
+/// wrap Riverside.MediaRecording on Windows and behave as no-ops elsewhere so the UI
+/// surface remains consistent across all platform heads.
+///
+public interface IRecordingFacade
+{
+ /// Gets the current recording lifecycle state.
+ FacadeRecordingState State { get; }
+
+ /// Gets the elapsed active recording duration.
+ TimeSpan Duration { get; }
+
+ /// Gets a human-readable name of the source the facade will capture (e.g. "Primary display").
+ string? SourceName { get; }
+
+ /// Gets a value indicating whether recording is supported on the current platform.
+ bool IsSupported { get; }
+
+ /// Raised when or change.
+ event EventHandler? StateChanged;
+
+ /// Sets the capture source for the next recording session.
+ void SetSource(Models.Recording.CaptureSource source) { }
+
+ /// Gets the path to the most recently captured frame, if any.
+ string? GetLatestFramePath() => null;
+
+ /// Starts a new recording session if one is not already active.
+ Task StartAsync(CancellationToken cancellationToken = default);
+
+ /// Pauses the active session.
+ Task PauseAsync(CancellationToken cancellationToken = default);
+
+ /// Resumes the active session if it is paused.
+ Task ResumeAsync(CancellationToken cancellationToken = default);
+
+ /// Stops the active session and finalises the output file.
+ Task StopAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/LinuxCaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/LinuxCaptureSourceProvider.cs
new file mode 100644
index 0000000..35c2a59
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/LinuxCaptureSourceProvider.cs
@@ -0,0 +1,391 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Riverside.Elapsed.App.Models.Recording;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+public sealed class LinuxCaptureSourceProvider : ICaptureSourceProvider
+{
+ private const int ZPixmap = 2;
+ private const ulong AllPlanes = ~0UL;
+
+ public async Task> GetSourcesAsync(CaptureSourceKind kind)
+ {
+ return kind switch
+ {
+ CaptureSourceKind.Screen => EnumerateScreens(),
+ CaptureSourceKind.Window => EnumerateWindows(),
+ CaptureSourceKind.Camera => await EnumerateCamerasAsync(),
+ _ => []
+ };
+ }
+
+ public async Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ var frame = await Task.Run(() => CaptureFrameCore(source));
+ if (frame is null) return null;
+
+ var bmpData = EncodeBmp(frame.Pixels, frame.Width, frame.Height);
+ var path = Path.Combine(Path.GetTempPath(), $"elapsed-preview-{Guid.NewGuid():N}.bmp");
+ File.WriteAllBytes(path, bmpData);
+ return new BitmapImage(new Uri(path));
+ }
+
+ public Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ return Task.Run(() =>
+ {
+ var frame = CaptureFrameCore(source);
+ if (frame is null) return null;
+ return (byte[]?)EncodeBmp(frame.Pixels, frame.Width, frame.Height);
+ });
+ }
+
+ public Task CaptureFrameAsync(CaptureSource source)
+ {
+ return Task.Run(() => CaptureFrameCore(source));
+ }
+
+ public async Task RefreshThumbnailAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ var frame = await Task.Run(() => CaptureFrameCore(source)).ConfigureAwait(true);
+ if (frame is not null)
+ source.BlitThumbnail(frame.Pixels, frame.Width, frame.Height);
+ }
+
+ private static CapturedFrame? CaptureFrameCore(CaptureSource source)
+ {
+ var display = X11.XOpenDisplay(null);
+ if (display == nint.Zero) return null;
+
+ try
+ {
+ if (source.Kind == CaptureSourceKind.Screen)
+ {
+ if (!int.TryParse(source.Id.Replace("screen-", ""), out int screenIndex))
+ return null;
+
+ var root = X11.XRootWindow(display, screenIndex);
+ int w = X11.XDisplayWidth(display, screenIndex);
+ int h = X11.XDisplayHeight(display, screenIndex);
+ if (w <= 0 || h <= 0) return null;
+
+ return CaptureDrawable(display, root, w, h);
+ }
+ else if (source.Kind == CaptureSourceKind.Window)
+ {
+ if (!nint.TryParse(source.Id.Replace("window-", ""), out var windowId))
+ return null;
+
+ X11.XGetWindowAttributes(display, windowId, out var attrs);
+ if (attrs.width <= 0 || attrs.height <= 0) return null;
+
+ return CaptureDrawable(display, windowId, attrs.width, attrs.height);
+ }
+
+ return null;
+ }
+ finally
+ {
+ X11.XCloseDisplay(display);
+ }
+ }
+
+ private static CapturedFrame? CaptureDrawable(nint display, nint drawable, int width, int height)
+ {
+ var xImage = X11.XGetImage(display, drawable, 0, 0, (uint)width, (uint)height, AllPlanes, ZPixmap);
+ if (xImage == nint.Zero) return null;
+
+ try
+ {
+ var imageInfo = Marshal.PtrToStructure(xImage);
+ if (imageInfo.data == nint.Zero || imageInfo.bits_per_pixel != 32)
+ return null;
+
+ int stride = imageInfo.bytes_per_line;
+ var pixels = new byte[height * width * 4];
+
+ for (int y = 0; y < height; y++)
+ {
+ Marshal.Copy(imageInfo.data + y * stride, pixels, y * width * 4, width * 4);
+ }
+
+ return new CapturedFrame(pixels, width, height);
+ }
+ finally
+ {
+ X11.XDestroyImage(xImage);
+ }
+ }
+
+ private static List EnumerateScreens()
+ {
+ List results = [];
+ var display = X11.XOpenDisplay(null);
+ if (display == nint.Zero) return results;
+
+ try
+ {
+ int screenCount = X11.XScreenCount(display);
+ for (int i = 0; i < screenCount; i++)
+ {
+ int w = X11.XDisplayWidth(display, i);
+ int h = X11.XDisplayHeight(display, i);
+
+ results.Add(new CaptureSource
+ {
+ Id = $"screen-{i}",
+ Name = i == 0 ? "Primary Display" : $"Display {i + 1}",
+ Resolution = $"{w}x{h}",
+ Kind = CaptureSourceKind.Screen,
+ });
+ }
+ }
+ finally
+ {
+ X11.XCloseDisplay(display);
+ }
+
+ return results;
+ }
+
+ private static List EnumerateWindows()
+ {
+ List results = [];
+ var display = X11.XOpenDisplay(null);
+ if (display == nint.Zero) return results;
+
+ try
+ {
+ int ownPid = Environment.ProcessId;
+ var root = X11.XRootWindow(display, 0);
+ var netWmName = X11.XInternAtom(display, "_NET_WM_NAME", false);
+ var utf8String = X11.XInternAtom(display, "UTF8_STRING", false);
+ var netWmPid = X11.XInternAtom(display, "_NET_WM_PID", false);
+
+ if (X11.XQueryTree(display, root, out _, out _, out var childrenPtr, out uint nChildren) == 0)
+ return results;
+
+ if (childrenPtr == nint.Zero) return results;
+
+ try
+ {
+ for (int i = 0; i < (int)nChildren; i++)
+ {
+ var child = Marshal.ReadIntPtr(childrenPtr + i * nint.Size);
+
+ X11.XGetWindowAttributes(display, child, out var attrs);
+ if (attrs.map_state != 2) continue;
+ if (attrs.width <= 1 || attrs.height <= 1) continue;
+
+ int windowPid = GetWindowPid(display, child, netWmPid);
+ if (windowPid == ownPid) continue;
+
+ string? name = GetWindowName(display, child, netWmName, utf8String);
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ X11.XFetchName(display, child, out var namePtr);
+ if (namePtr != nint.Zero)
+ {
+ name = Marshal.PtrToStringUTF8(namePtr);
+ X11.XFree(namePtr);
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(name)) continue;
+
+ results.Add(new CaptureSource
+ {
+ Id = $"window-{child}",
+ Name = name!,
+ Resolution = $"{attrs.width}x{attrs.height}",
+ Kind = CaptureSourceKind.Window,
+ });
+ }
+ }
+ finally
+ {
+ X11.XFree(childrenPtr);
+ }
+ }
+ finally
+ {
+ X11.XCloseDisplay(display);
+ }
+
+ return results;
+ }
+
+ private static string? GetWindowName(nint display, nint window, nint netWmName, nint utf8String)
+ {
+ int result = X11.XGetWindowProperty(display, window, netWmName, 0, 1024, false, utf8String,
+ out _, out int actualFormat, out nuint nItems, out _, out var prop);
+
+ if (result != 0 || prop == nint.Zero || nItems == 0)
+ return null;
+
+ try
+ {
+ return Marshal.PtrToStringUTF8(prop);
+ }
+ finally
+ {
+ X11.XFree(prop);
+ }
+ }
+
+ private static int GetWindowPid(nint display, nint window, nint netWmPid)
+ {
+ int result = X11.XGetWindowProperty(display, window, netWmPid, 0, 1, false, nint.Zero,
+ out _, out int format, out nuint nItems, out _, out var prop);
+
+ if (result != 0 || prop == nint.Zero || nItems == 0)
+ return 0;
+
+ try
+ {
+ return Marshal.ReadInt32(prop);
+ }
+ finally
+ {
+ X11.XFree(prop);
+ }
+ }
+
+ private static async Task> EnumerateCamerasAsync()
+ {
+ try
+ {
+ var cameras = await FFmpegService.EnumerateCamerasAsync();
+ List results = [];
+
+ foreach (var (id, name) in cameras)
+ {
+ var source = new CaptureSource
+ {
+ Id = id,
+ Name = name,
+ Kind = CaptureSourceKind.Camera,
+ };
+
+ try
+ {
+ var thumbPath = Path.Combine(Path.GetTempPath(), $"elapsed-cam-{Guid.NewGuid():N}.jpg");
+ var grabbed = await FFmpegService.GrabCameraFrameAsync(id, thumbPath);
+ if (grabbed is not null)
+ {
+ using var codec = SkiaSharp.SKCodec.Create(thumbPath);
+ if (codec is not null)
+ source.Resolution = $"{codec.Info.Width}x{codec.Info.Height}";
+
+ source.Thumbnail = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri(thumbPath));
+ }
+ }
+ catch (Exception) { }
+
+ results.Add(source);
+ }
+
+ return results;
+ }
+ catch (Exception)
+ {
+ return [];
+ }
+ }
+
+ private static byte[] EncodeBmp(byte[] bgraPixels, int width, int height)
+ => BmpEncoder.Encode(bgraPixels, width, height, topDown: true);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct XImage
+ {
+ public int width, height;
+ public int xoffset;
+ public int format;
+ public nint data;
+ public int byte_order;
+ public int bitmap_unit;
+ public int bitmap_bit_order;
+ public int bitmap_pad;
+ public int depth;
+ public int bytes_per_line;
+ public int bits_per_pixel;
+ public ulong red_mask, green_mask, blue_mask;
+ public nint obdata;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct XWindowAttributes
+ {
+ public int x, y;
+ public int width, height;
+ public int border_width;
+ public int depth;
+ public nint visual;
+ public nint root;
+ public int @class;
+ public int bit_gravity;
+ public int win_gravity;
+ public int backing_store;
+ public ulong backing_planes;
+ public ulong backing_pixel;
+ public int save_under;
+ public nint colormap;
+ public int map_installed;
+ public int map_state;
+ public long all_event_masks;
+ public long your_event_mask;
+ public long do_not_propagate_mask;
+ public int override_redirect;
+ public nint screen;
+ }
+
+ private static class X11
+ {
+ const string Lib = "libX11.so.6";
+
+ [DllImport(Lib)]
+ public static extern nint XOpenDisplay(string? displayName);
+
+ [DllImport(Lib)]
+ public static extern int XCloseDisplay(nint display);
+
+ [DllImport(Lib)]
+ public static extern nint XRootWindow(nint display, int screenNumber);
+
+ [DllImport(Lib)]
+ public static extern int XScreenCount(nint display);
+
+ [DllImport(Lib)]
+ public static extern int XDisplayWidth(nint display, int screenNumber);
+
+ [DllImport(Lib)]
+ public static extern int XDisplayHeight(nint display, int screenNumber);
+
+ [DllImport(Lib)]
+ public static extern nint XGetImage(nint display, nint drawable, int x, int y, uint width, uint height, ulong planeMask, int format);
+
+ [DllImport(Lib)]
+ public static extern int XDestroyImage(nint ximage);
+
+ [DllImport(Lib)]
+ public static extern int XQueryTree(nint display, nint window, out nint rootReturn, out nint parentReturn, out nint childrenReturn, out uint nChildrenReturn);
+
+ [DllImport(Lib)]
+ public static extern int XFree(nint data);
+
+ [DllImport(Lib)]
+ public static extern int XGetWindowAttributes(nint display, nint window, out XWindowAttributes attributes);
+
+ [DllImport(Lib)]
+ public static extern int XFetchName(nint display, nint window, out nint name);
+
+ [DllImport(Lib)]
+ public static extern int XGetWindowProperty(nint display, nint window, nint property, long offset, long length, bool delete, nint reqType, out nint actualType, out int actualFormat, out nuint nItems, out nuint bytesAfter, out nint prop);
+
+ [DllImport(Lib)]
+ public static extern nint XInternAtom(nint display, [MarshalAs(UnmanagedType.LPUTF8Str)] string name, bool onlyIfExists);
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/MacCaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/MacCaptureSourceProvider.cs
new file mode 100644
index 0000000..29db473
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/MacCaptureSourceProvider.cs
@@ -0,0 +1,455 @@
+using System.Runtime.InteropServices;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Riverside.Elapsed.App.Models.Recording;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+public sealed class MacCaptureSourceProvider : ICaptureSourceProvider
+{
+ public async Task> GetSourcesAsync(CaptureSourceKind kind)
+ {
+ return kind switch
+ {
+ CaptureSourceKind.Screen => EnumerateScreens(),
+ CaptureSourceKind.Window => EnumerateWindows(),
+ CaptureSourceKind.Camera => await EnumerateCamerasAsync(),
+ _ => []
+ };
+ }
+
+ public async Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ var frame = await Task.Run(() => CaptureFrameCore(source));
+ if (frame is null) return null;
+
+ var bmpData = EncodeBmp(frame.Pixels, frame.Width, frame.Height);
+ var path = Path.Combine(Path.GetTempPath(), $"elapsed-preview-{Guid.NewGuid():N}.bmp");
+ File.WriteAllBytes(path, bmpData);
+ return new BitmapImage(new Uri(path));
+ }
+
+ public Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ return Task.Run(() =>
+ {
+ var frame = CaptureFrameCore(source);
+ if (frame is null) return null;
+ return (byte[]?)EncodeBmp(frame.Pixels, frame.Width, frame.Height);
+ });
+ }
+
+ public Task CaptureFrameAsync(CaptureSource source)
+ {
+ return Task.Run(() => CaptureFrameCore(source));
+ }
+
+ public async Task RefreshThumbnailAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ var frame = await Task.Run(() => CaptureFrameCore(source)).ConfigureAwait(true);
+ if (frame is not null)
+ source.BlitThumbnail(frame.Pixels, frame.Width, frame.Height);
+ }
+
+ private static CapturedFrame? CaptureFrameCore(CaptureSource source)
+ {
+ if (source.Kind == CaptureSourceKind.Screen)
+ {
+ if (!uint.TryParse(source.Id.Replace("screen-", ""), out uint displayId))
+ return null;
+
+ var imageRef = CG.CGDisplayCreateImage(displayId);
+ if (imageRef == nint.Zero) return null;
+
+ try
+ {
+ return ExtractPixelsFromCGImage(imageRef);
+ }
+ finally
+ {
+ CF.CFRelease(imageRef);
+ }
+ }
+ else if (source.Kind == CaptureSourceKind.Window)
+ {
+ if (!uint.TryParse(source.Id.Replace("window-", ""), out uint windowId))
+ return null;
+
+ var imageRef = CG.CGWindowListCreateImage(
+ CGRect.Null,
+ CGWindowListOption.IncludingWindow,
+ windowId,
+ CGWindowImageOption.BoundsIgnoreFraming);
+
+ if (imageRef == nint.Zero) return null;
+
+ try
+ {
+ return ExtractPixelsFromCGImage(imageRef);
+ }
+ finally
+ {
+ CF.CFRelease(imageRef);
+ }
+ }
+
+ return null;
+ }
+
+ private static CapturedFrame? ExtractPixelsFromCGImage(nint cgImage)
+ {
+ int width = (int)CG.CGImageGetWidth(cgImage);
+ int height = (int)CG.CGImageGetHeight(cgImage);
+ if (width == 0 || height == 0) return null;
+
+ var dataProvider = CG.CGImageGetDataProvider(cgImage);
+ if (dataProvider == nint.Zero) return null;
+
+ var cfData = CG.CGDataProviderCopyData(dataProvider);
+ if (cfData == nint.Zero) return null;
+
+ try
+ {
+ var ptr = CF.CFDataGetBytePtr(cfData);
+ var length = (int)CF.CFDataGetLength(cfData);
+
+ var bitmapInfo = CG.CGImageGetBitmapInfo(cgImage);
+ int bpp = (int)CG.CGImageGetBitsPerPixel(cgImage);
+
+ if (bpp != 32) return null;
+
+ var pixels = new byte[length];
+ Marshal.Copy(ptr, pixels, 0, length);
+
+ var alphaInfo = (CGImageAlphaInfo)(bitmapInfo & 0x1F);
+ var byteOrder = bitmapInfo & (uint)CGBitmapInfo.ByteOrderMask;
+
+ bool needsSwizzle = byteOrder == (uint)CGBitmapInfo.ByteOrder32Big ||
+ alphaInfo == CGImageAlphaInfo.PremultipliedFirst ||
+ alphaInfo == CGImageAlphaInfo.First ||
+ alphaInfo == CGImageAlphaInfo.NoneSkipFirst;
+
+ if (needsSwizzle)
+ {
+ for (int i = 0; i < pixels.Length; i += 4)
+ {
+ (pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]) =
+ (pixels[i + 2], pixels[i + 1], pixels[i], pixels[i + 3]);
+ }
+ }
+
+ return new CapturedFrame(pixels, width, height);
+ }
+ finally
+ {
+ CF.CFRelease(cfData);
+ }
+ }
+
+ private static List EnumerateScreens()
+ {
+ List results = [];
+ var displays = new uint[16];
+ CG.CGGetActiveDisplayList(16, displays, out uint count);
+
+ for (int i = 0; i < (int)count; i++)
+ {
+ var bounds = CG.CGDisplayBounds(displays[i]);
+ int w = (int)bounds.width;
+ int h = (int)bounds.height;
+ bool isPrimary = CG.CGDisplayIsMain(displays[i]) != 0;
+
+ results.Add(new CaptureSource
+ {
+ Id = $"screen-{displays[i]}",
+ Name = isPrimary ? "Primary Display" : $"Display {i + 1}",
+ Resolution = $"{w}x{h}",
+ Kind = CaptureSourceKind.Screen,
+ });
+ }
+
+ return results;
+ }
+
+ private static List EnumerateWindows()
+ {
+ List results = [];
+ int ownPid = Environment.ProcessId;
+
+ var windowList = CG.CGWindowListCopyWindowInfo(
+ CGWindowListOption.OnScreenOnly | CGWindowListOption.ExcludeDesktopElements, 0);
+
+ if (windowList == nint.Zero) return results;
+
+ try
+ {
+ int count = (int)CF.CFArrayGetCount(windowList);
+ for (int i = 0; i < count; i++)
+ {
+ var dict = CF.CFArrayGetValueAtIndex(windowList, i);
+ if (dict == nint.Zero) continue;
+
+ int layer = GetCFDictInt(dict, "kCGWindowLayer");
+ if (layer != 0) continue;
+
+ int pid = GetCFDictInt(dict, "kCGWindowOwnerPID");
+ if (pid == ownPid) continue;
+
+ string? name = GetCFDictString(dict, "kCGWindowName");
+ string? owner = GetCFDictString(dict, "kCGWindowOwnerName");
+ int windowId = GetCFDictInt(dict, "kCGWindowNumber");
+
+ if (string.IsNullOrEmpty(name) && string.IsNullOrEmpty(owner)) continue;
+ if (windowId == 0) continue;
+
+ var bounds = GetCFDictBounds(dict, "kCGWindowBounds");
+ string resolution = bounds.HasValue
+ ? $"{(int)bounds.Value.width}x{(int)bounds.Value.height}"
+ : "";
+
+ results.Add(new CaptureSource
+ {
+ Id = $"window-{windowId}",
+ Name = string.IsNullOrEmpty(name) ? (owner ?? "Unknown") : name,
+ Description = owner,
+ Resolution = resolution,
+ Kind = CaptureSourceKind.Window,
+ });
+ }
+ }
+ finally
+ {
+ CF.CFRelease(windowList);
+ }
+
+ return results;
+ }
+
+ private static async Task> EnumerateCamerasAsync()
+ {
+ try
+ {
+ var cameras = await FFmpegService.EnumerateCamerasAsync();
+ List results = [];
+
+ foreach (var (id, name) in cameras)
+ {
+ var source = new CaptureSource
+ {
+ Id = id,
+ Name = name,
+ Kind = CaptureSourceKind.Camera,
+ };
+
+ try
+ {
+ var thumbPath = Path.Combine(Path.GetTempPath(), $"elapsed-cam-{Guid.NewGuid():N}.jpg");
+ var grabbed = await FFmpegService.GrabCameraFrameAsync(id, thumbPath);
+ if (grabbed is not null)
+ {
+ using var codec = SkiaSharp.SKCodec.Create(thumbPath);
+ if (codec is not null)
+ source.Resolution = $"{codec.Info.Width}x{codec.Info.Height}";
+
+ source.Thumbnail = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(new Uri(thumbPath));
+ }
+ }
+ catch (Exception) { }
+
+ results.Add(source);
+ }
+
+ return results;
+ }
+ catch (Exception)
+ {
+ return [];
+ }
+ }
+
+ private static int GetCFDictInt(nint dict, string key)
+ {
+ using var keyStr = new CFString(key);
+ var value = CF.CFDictionaryGetValue(dict, keyStr.Handle);
+ if (value == nint.Zero) return 0;
+ CF.CFNumberGetValue(value, 3 /* kCFNumberSInt32Type */, out int result);
+ return result;
+ }
+
+ private static string? GetCFDictString(nint dict, string key)
+ {
+ using var keyStr = new CFString(key);
+ var value = CF.CFDictionaryGetValue(dict, keyStr.Handle);
+ if (value == nint.Zero) return null;
+
+ var length = CF.CFStringGetLength(value);
+ if (length == 0) return "";
+
+ var buf = CF.CFStringGetCStringPtr(value, 0x08000100 /* kCFStringEncodingUTF8 */);
+ if (buf != nint.Zero)
+ return Marshal.PtrToStringUTF8(buf);
+
+ var buffer = new byte[(int)length * 4 + 1];
+ unsafe
+ {
+ fixed (byte* p = buffer)
+ {
+ if (CF.CFStringGetCString(value, (nint)p, buffer.Length, 0x08000100))
+ return Marshal.PtrToStringUTF8((nint)p);
+ }
+ }
+ return null;
+ }
+
+ private static CGRect? GetCFDictBounds(nint dict, string key)
+ {
+ using var keyStr = new CFString(key);
+ var value = CF.CFDictionaryGetValue(dict, keyStr.Handle);
+ if (value == nint.Zero) return null;
+
+ if (CG.CGRectMakeWithDictionaryRepresentation(value, out var rect))
+ return rect;
+ return null;
+ }
+
+ private static byte[] EncodeBmp(byte[] bgraPixels, int width, int height)
+ => BmpEncoder.Encode(bgraPixels, width, height, topDown: true);
+
+ private sealed class CFString : IDisposable
+ {
+ public nint Handle { get; }
+
+ public CFString(string value)
+ {
+ Handle = CF.CFStringCreateWithCString(nint.Zero, value, 0x08000100);
+ }
+
+ public void Dispose()
+ {
+ if (Handle != nint.Zero)
+ CF.CFRelease(Handle);
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct CGRect
+ {
+ public double x, y, width, height;
+ public static CGRect Null => new() { x = double.PositiveInfinity, y = double.PositiveInfinity, width = 0, height = 0 };
+ }
+
+ private enum CGWindowListOption : uint
+ {
+ All = 0,
+ OnScreenOnly = 1 << 0,
+ OnScreenAboveWindow = 1 << 1,
+ OnScreenBelowWindow = 1 << 2,
+ IncludingWindow = 1 << 3,
+ ExcludeDesktopElements = 1 << 4,
+ }
+
+ private enum CGWindowImageOption : uint
+ {
+ Default = 0,
+ BoundsIgnoreFraming = 1 << 0,
+ }
+
+ private enum CGImageAlphaInfo : uint
+ {
+ None = 0,
+ PremultipliedLast = 1,
+ PremultipliedFirst = 2,
+ Last = 3,
+ First = 4,
+ NoneSkipLast = 5,
+ NoneSkipFirst = 6,
+ }
+
+ private enum CGBitmapInfo : uint
+ {
+ ByteOrderMask = 0x7000,
+ ByteOrder32Big = 2 << 12,
+ ByteOrder32Little = 4 << 12,
+ }
+
+ private static class CG
+ {
+ const string Lib = "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics";
+
+ [DllImport(Lib)]
+ public static extern uint CGGetActiveDisplayList(uint maxDisplays, [Out] uint[] activeDisplays, out uint displayCount);
+
+ [DllImport(Lib)]
+ public static extern nint CGDisplayCreateImage(uint displayId);
+
+ [DllImport(Lib)]
+ public static extern uint CGDisplayIsMain(uint display);
+
+ [DllImport(Lib)]
+ public static extern CGRect CGDisplayBounds(uint display);
+
+ [DllImport(Lib)]
+ public static extern nint CGWindowListCreateImage(CGRect screenBounds, CGWindowListOption listOption, uint windowId, CGWindowImageOption imageOption);
+
+ [DllImport(Lib)]
+ public static extern nint CGWindowListCopyWindowInfo(CGWindowListOption option, uint relativeToWindow);
+
+ [DllImport(Lib)]
+ public static extern nuint CGImageGetWidth(nint image);
+
+ [DllImport(Lib)]
+ public static extern nuint CGImageGetHeight(nint image);
+
+ [DllImport(Lib)]
+ public static extern uint CGImageGetBitmapInfo(nint image);
+
+ [DllImport(Lib)]
+ public static extern nuint CGImageGetBitsPerPixel(nint image);
+
+ [DllImport(Lib)]
+ public static extern nint CGImageGetDataProvider(nint image);
+
+ [DllImport(Lib)]
+ public static extern nint CGDataProviderCopyData(nint provider);
+
+ [DllImport(Lib)]
+ public static extern bool CGRectMakeWithDictionaryRepresentation(nint dict, out CGRect rect);
+ }
+
+ private static class CF
+ {
+ const string Lib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
+
+ [DllImport(Lib)]
+ public static extern void CFRelease(nint cf);
+
+ [DllImport(Lib)]
+ public static extern nint CFDataGetBytePtr(nint cfData);
+
+ [DllImport(Lib)]
+ public static extern nint CFDataGetLength(nint cfData);
+
+ [DllImport(Lib)]
+ public static extern nint CFArrayGetCount(nint array);
+
+ [DllImport(Lib)]
+ public static extern nint CFArrayGetValueAtIndex(nint array, nint index);
+
+ [DllImport(Lib)]
+ public static extern nint CFDictionaryGetValue(nint dict, nint key);
+
+ [DllImport(Lib)]
+ public static extern bool CFNumberGetValue(nint number, int theType, out int value);
+
+ [DllImport(Lib)]
+ public static extern nint CFStringGetLength(nint str);
+
+ [DllImport(Lib)]
+ public static extern nint CFStringGetCStringPtr(nint str, uint encoding);
+
+ [DllImport(Lib)]
+ public static extern bool CFStringGetCString(nint str, nint buffer, int bufferSize, uint encoding);
+
+ [DllImport(Lib)]
+ public static extern nint CFStringCreateWithCString(nint alloc, [MarshalAs(UnmanagedType.LPUTF8Str)] string cStr, uint encoding);
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs
new file mode 100644
index 0000000..669fb60
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs
@@ -0,0 +1,21 @@
+using Riverside.Elapsed.App.Models.Recording;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+public sealed class NoOpCaptureSourceProvider : ICaptureSourceProvider
+{
+ public Task> GetSourcesAsync(CaptureSourceKind kind)
+ => Task.FromResult>([]);
+
+ public Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight)
+ => Task.FromResult(null);
+
+ public Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight)
+ => Task.FromResult(null);
+
+ public Task CaptureFrameAsync(CaptureSource source)
+ => Task.FromResult(null);
+
+ public Task RefreshThumbnailAsync(CaptureSource source, int maxWidth, int maxHeight)
+ => Task.CompletedTask;
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/TimelapseRecordingFacade.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/TimelapseRecordingFacade.cs
new file mode 100644
index 0000000..354dcf6
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/TimelapseRecordingFacade.cs
@@ -0,0 +1,229 @@
+using System.Diagnostics;
+using Riverside.Elapsed.App.Models.Recording;
+using SkiaSharp;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+internal sealed class TimelapseRecordingFacade : IRecordingFacade
+{
+ private readonly ICaptureSourceProvider _sourceProvider;
+ private readonly Stopwatch _stopwatch = new();
+
+ private CaptureSource? _source;
+ private string? _framesDirectory;
+ private CancellationTokenSource? _captureCts;
+ private Task? _captureLoop;
+ private Process? _cameraProcess;
+ private int _frameCount;
+ private FacadeRecordingState _state = FacadeRecordingState.Idle;
+ private bool _paused;
+
+ public TimelapseRecordingFacade(ICaptureSourceProvider sourceProvider)
+ {
+ _sourceProvider = sourceProvider;
+ }
+
+ public FacadeRecordingState State => _state;
+ public TimeSpan Duration => _stopwatch.Elapsed;
+ public string? SourceName { get; private set; }
+ public bool IsSupported => true;
+ public event EventHandler? StateChanged;
+
+ public void SetSource(CaptureSource source)
+ {
+ _source = source;
+ SourceName = source.Name;
+ }
+
+ public string? GetLatestFramePath()
+ {
+ if (_framesDirectory is null || !Directory.Exists(_framesDirectory))
+ return null;
+
+ var files = Directory.GetFiles(_framesDirectory, "frame-*.jpg");
+ if (files.Length == 0) return null;
+
+ Array.Sort(files);
+ return files[^1];
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ if (_source is null)
+ throw new InvalidOperationException("No capture source set. Call SetSource before starting.");
+
+ if (!await FFmpegService.IsAvailableAsync())
+ throw new InvalidOperationException(
+ "FFmpeg is required but was not found. " +
+ "Please install FFmpeg and ensure it is available on your system PATH.");
+
+ var recordingId = Guid.NewGuid().ToString("N");
+ _framesDirectory = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Riverside", "Elapsed", "recordings", recordingId);
+ Directory.CreateDirectory(_framesDirectory);
+
+ _frameCount = 0;
+ _paused = false;
+ _captureCts = new CancellationTokenSource();
+
+ if (_source.Kind == CaptureSourceKind.Camera)
+ {
+ _cameraProcess = FFmpegService.StartCameraCapture(_source.Id, _framesDirectory);
+ }
+ else
+ {
+ _captureLoop = Task.Run(() => CaptureLoopAsync(_captureCts.Token));
+ }
+
+ _stopwatch.Restart();
+ _state = FacadeRecordingState.Recording;
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ public Task PauseAsync(CancellationToken cancellationToken = default)
+ {
+ _paused = true;
+ _stopwatch.Stop();
+ _state = FacadeRecordingState.Paused;
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ return Task.CompletedTask;
+ }
+
+ public Task ResumeAsync(CancellationToken cancellationToken = default)
+ {
+ _paused = false;
+ _stopwatch.Start();
+ _state = FacadeRecordingState.Recording;
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ return Task.CompletedTask;
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ _stopwatch.Stop();
+
+ if (_cameraProcess is not null)
+ {
+ try
+ {
+ if (!_cameraProcess.HasExited)
+ _cameraProcess.Kill();
+ await _cameraProcess.WaitForExitAsync(cancellationToken);
+ }
+ catch (Exception) { }
+ _cameraProcess.Dispose();
+ _cameraProcess = null;
+ }
+
+ if (_captureCts is not null)
+ {
+ await _captureCts.CancelAsync();
+ if (_captureLoop is not null)
+ {
+ try { await _captureLoop; } catch (OperationCanceledException) { }
+ }
+ _captureCts.Dispose();
+ _captureCts = null;
+ _captureLoop = null;
+ }
+
+ var duration = _stopwatch.Elapsed;
+ string? mp4Path = null;
+
+ int actualFrameCount = _framesDirectory is not null
+ ? Directory.GetFiles(_framesDirectory, "frame-*.jpg").Length
+ : 0;
+
+ if (_framesDirectory is not null && actualFrameCount > 0)
+ {
+ mp4Path = Path.Combine(
+ Path.GetDirectoryName(_framesDirectory)!,
+ $"timelapse-{DateTime.Now:yyyyMMdd-HHmmss}.mp4");
+
+ await FFmpegService.EncodeFramesToVideoAsync(
+ _framesDirectory, mp4Path, frameRate: 24.0, ct: cancellationToken);
+
+ try { Directory.Delete(_framesDirectory, recursive: true); } catch (Exception) { }
+ }
+ else if (_framesDirectory is not null)
+ {
+ try { Directory.Delete(_framesDirectory, recursive: true); } catch (Exception) { }
+ }
+
+ _framesDirectory = null;
+ _state = FacadeRecordingState.Idle;
+ StateChanged?.Invoke(this, EventArgs.Empty);
+
+ return new FacadeRecordingResult(mp4Path, duration);
+ }
+
+ private async Task CaptureLoopAsync(CancellationToken ct)
+ {
+ var interval = TimeSpan.FromSeconds(1);
+ int consecutiveFailures = 0;
+
+ while (!ct.IsCancellationRequested)
+ {
+ if (!_paused && _source is not null)
+ {
+ try
+ {
+ var frame = await _sourceProvider.CaptureFrameAsync(_source);
+ if (frame is not null)
+ {
+ SaveFrameAsJpeg(frame);
+ consecutiveFailures = 0;
+ }
+ else
+ {
+ consecutiveFailures++;
+ }
+ }
+ catch (Exception)
+ {
+ consecutiveFailures++;
+ }
+ }
+
+ try { await Task.Delay(interval, ct); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+
+ private void SaveFrameAsJpeg(CapturedFrame frame)
+ {
+ _frameCount++;
+ var fileName = $"frame-{_frameCount:D6}.jpg";
+ var filePath = Path.Combine(_framesDirectory!, fileName);
+
+ var info = new SKImageInfo(frame.Width, frame.Height, SKColorType.Bgra8888, SKAlphaType.Opaque);
+ using var bitmap = new SKBitmap(info);
+
+ var pixels = frame.Pixels;
+ if (frame.IsBottomUp)
+ {
+ int stride = frame.Width * 4;
+ var flipped = new byte[pixels.Length];
+ for (int y = 0; y < frame.Height; y++)
+ {
+ var srcRow = pixels.AsSpan(y * stride, stride);
+ var dstRow = flipped.AsSpan((frame.Height - 1 - y) * stride, stride);
+ srcRow.CopyTo(dstRow);
+ }
+ pixels = flipped;
+ }
+
+ unsafe
+ {
+ fixed (byte* ptr = pixels)
+ {
+ bitmap.InstallPixels(info, (nint)ptr, info.RowBytes);
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Jpeg, 90);
+ using var stream = File.OpenWrite(filePath);
+ data.SaveTo(stream);
+ }
+ }
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs
new file mode 100644
index 0000000..5d5f294
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs
@@ -0,0 +1,742 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Text;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Riverside.Elapsed.App.Models.Recording;
+using SkiaSharp;
+
+namespace Riverside.Elapsed.App.Services.Recording;
+
+public sealed class WindowsCaptureSourceProvider : ICaptureSourceProvider
+{
+ private const int ThumbMaxWidth = 480;
+ private const int ThumbMaxHeight = 300;
+
+ public async Task> GetSourcesAsync(CaptureSourceKind kind)
+ {
+ if (kind == CaptureSourceKind.Camera)
+ return await EnumerateCamerasAsync();
+
+ var items = kind switch
+ {
+ CaptureSourceKind.Screen => EnumerateScreens(),
+ CaptureSourceKind.Window => EnumerateWindows(),
+ _ => []
+ };
+
+ foreach (var (source, pixels, tw, th) in items)
+ {
+ if (pixels is not null)
+ source.BlitThumbnail(pixels, tw, th, bottomUp: true);
+ }
+
+ return items.ConvertAll(i => i.source);
+ }
+
+ public async Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ var tempPath = await Task.Run(() =>
+ {
+ byte[]? pixels = null;
+ int tw = 0, th = 0;
+
+ if (source.Kind == CaptureSourceKind.Screen)
+ {
+ int index = 0;
+ int targetIndex = int.Parse(source.Id.Replace("monitor-", ""));
+ Native.MonitorEnumProc callback = (nint hMonitor, nint hdcMonitor, ref Native.RECT lprcMonitor, nint dwData) =>
+ {
+ if (index == targetIndex)
+ {
+ var mi = new Native.MONITORINFOEX();
+ mi.cbSize = Marshal.SizeOf();
+ if (Native.GetMonitorInfoW(hMonitor, ref mi))
+ {
+ int w = mi.rcMonitor.right - mi.rcMonitor.left;
+ int h = mi.rcMonitor.bottom - mi.rcMonitor.top;
+ (tw, th) = ScaleToFit(w, h, maxWidth, maxHeight);
+ pixels = CaptureScreenPixels(mi.rcMonitor.left, mi.rcMonitor.top, w, h, tw, th);
+ }
+ index++;
+ return false;
+ }
+ index++;
+ return true;
+ };
+ Native.EnumDisplayMonitors(nint.Zero, nint.Zero, callback, nint.Zero);
+ GC.KeepAlive(callback);
+ }
+ else if (source.Kind == CaptureSourceKind.Window)
+ {
+ var hWnd = nint.Parse(source.Id.Replace("window-", ""));
+ Native.GetWindowRect(hWnd, out var rect);
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ if (w > 1 && h > 1)
+ {
+ (tw, th) = ScaleToFit(w, h, maxWidth, maxHeight);
+ pixels = CaptureWindowPixels(hWnd, w, h, tw, th);
+ }
+ }
+
+ if (pixels is null)
+ return null;
+
+ var bmpData = EncodeBmp(pixels, tw, th);
+ var path = Path.Combine(Path.GetTempPath(), $"elapsed-preview-{Guid.NewGuid():N}.bmp");
+ File.WriteAllBytes(path, bmpData);
+ return path;
+ }).ConfigureAwait(true);
+
+ if (tempPath is null)
+ return null;
+
+ return new BitmapImage(new Uri(tempPath));
+ }
+
+ public Task CaptureFrameAsync(CaptureSource source)
+ {
+ return Task.Run(() =>
+ {
+ if (source.Kind == CaptureSourceKind.Screen)
+ {
+ byte[]? pixels = null;
+ int capturedW = 0, capturedH = 0;
+ int index = 0;
+ int targetIndex = int.Parse(source.Id.Replace("monitor-", ""));
+ Native.MonitorEnumProc callback = (nint hMonitor, nint hdcMonitor, ref Native.RECT lprcMonitor, nint dwData) =>
+ {
+ if (index == targetIndex)
+ {
+ var mi = new Native.MONITORINFOEX();
+ mi.cbSize = Marshal.SizeOf();
+ if (Native.GetMonitorInfoW(hMonitor, ref mi))
+ {
+ capturedW = mi.rcMonitor.right - mi.rcMonitor.left;
+ capturedH = mi.rcMonitor.bottom - mi.rcMonitor.top;
+ pixels = CaptureScreenPixels(mi.rcMonitor.left, mi.rcMonitor.top, capturedW, capturedH, capturedW, capturedH);
+ }
+ index++;
+ return false;
+ }
+ index++;
+ return true;
+ };
+ Native.EnumDisplayMonitors(nint.Zero, nint.Zero, callback, nint.Zero);
+ GC.KeepAlive(callback);
+ return pixels is null ? null : new CapturedFrame(pixels, capturedW, capturedH, IsBottomUp: true);
+ }
+ else if (source.Kind == CaptureSourceKind.Window)
+ {
+ var hWnd = nint.Parse(source.Id.Replace("window-", ""));
+ Native.GetWindowRect(hWnd, out var rect);
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ if (w <= 1 || h <= 1) return null;
+
+ var pixels = CaptureWindowPixels(hWnd, w, h, w, h);
+ return pixels is null ? null : new CapturedFrame(pixels, w, h, IsBottomUp: true);
+ }
+
+ return (CapturedFrame?)null;
+ });
+ }
+
+ public async Task RefreshThumbnailAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ var result = await Task.Run(() =>
+ {
+ byte[]? pixels = null;
+ int tw = 0, th = 0;
+
+ if (source.Kind == CaptureSourceKind.Screen)
+ {
+ int index = 0;
+ int targetIndex = int.Parse(source.Id.Replace("monitor-", ""));
+ Native.MonitorEnumProc callback = (nint hMonitor, nint hdcMonitor, ref Native.RECT lprcMonitor, nint dwData) =>
+ {
+ if (index == targetIndex)
+ {
+ var mi = new Native.MONITORINFOEX();
+ mi.cbSize = Marshal.SizeOf();
+ if (Native.GetMonitorInfoW(hMonitor, ref mi))
+ {
+ int w = mi.rcMonitor.right - mi.rcMonitor.left;
+ int h = mi.rcMonitor.bottom - mi.rcMonitor.top;
+ (tw, th) = ScaleToFit(w, h, maxWidth, maxHeight);
+ pixels = CaptureScreenPixels(mi.rcMonitor.left, mi.rcMonitor.top, w, h, tw, th);
+ }
+ index++;
+ return false;
+ }
+ index++;
+ return true;
+ };
+ Native.EnumDisplayMonitors(nint.Zero, nint.Zero, callback, nint.Zero);
+ GC.KeepAlive(callback);
+ }
+ else if (source.Kind == CaptureSourceKind.Window)
+ {
+ var hWnd = nint.Parse(source.Id.Replace("window-", ""));
+ Native.GetWindowRect(hWnd, out var rect);
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ if (w > 1 && h > 1)
+ {
+ (tw, th) = ScaleToFit(w, h, maxWidth, maxHeight);
+ pixels = CaptureWindowPixels(hWnd, w, h, tw, th);
+ }
+ }
+
+ return pixels is not null ? (pixels, tw, th) : default((byte[], int, int)?);
+ }).ConfigureAwait(true);
+
+ if (result is var (pixels, tw, th))
+ source.BlitThumbnail(pixels, tw, th, bottomUp: true);
+ }
+
+ public Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight)
+ {
+ return Task.Run(() =>
+ {
+ byte[]? pixels = null;
+ int tw = 0, th = 0;
+
+ if (source.Kind == CaptureSourceKind.Screen)
+ {
+ int index = 0;
+ int targetIndex = int.Parse(source.Id.Replace("monitor-", ""));
+ Native.MonitorEnumProc callback = (nint hMonitor, nint hdcMonitor, ref Native.RECT lprcMonitor, nint dwData) =>
+ {
+ if (index == targetIndex)
+ {
+ var mi = new Native.MONITORINFOEX();
+ mi.cbSize = Marshal.SizeOf();
+ if (Native.GetMonitorInfoW(hMonitor, ref mi))
+ {
+ int w = mi.rcMonitor.right - mi.rcMonitor.left;
+ int h = mi.rcMonitor.bottom - mi.rcMonitor.top;
+ (tw, th) = ScaleToFit(w, h, maxWidth, maxHeight);
+ pixels = CaptureScreenPixels(mi.rcMonitor.left, mi.rcMonitor.top, w, h, tw, th);
+ }
+ index++;
+ return false;
+ }
+ index++;
+ return true;
+ };
+ Native.EnumDisplayMonitors(nint.Zero, nint.Zero, callback, nint.Zero);
+ GC.KeepAlive(callback);
+ }
+ else if (source.Kind == CaptureSourceKind.Window)
+ {
+ var hWnd = nint.Parse(source.Id.Replace("window-", ""));
+ Native.GetWindowRect(hWnd, out var rect);
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ if (w > 1 && h > 1)
+ {
+ (tw, th) = ScaleToFit(w, h, maxWidth, maxHeight);
+ pixels = CaptureWindowPixels(hWnd, w, h, tw, th);
+ }
+ }
+
+ if (pixels is null)
+ return null;
+
+ return (byte[]?)EncodeBmp(pixels, tw, th);
+ });
+ }
+
+ private List<(CaptureSource source, byte[]? pixels, int tw, int th)> EnumerateScreens()
+ {
+ List<(CaptureSource, byte[]?, int, int)> results = [];
+ int index = 0;
+
+ Native.MonitorEnumProc callback = (nint hMonitor, nint hdcMonitor, ref Native.RECT lprcMonitor, nint dwData) =>
+ {
+ var mi = new Native.MONITORINFOEX();
+ mi.cbSize = Marshal.SizeOf();
+ if (!Native.GetMonitorInfoW(hMonitor, ref mi))
+ return true;
+
+ int w = mi.rcMonitor.right - mi.rcMonitor.left;
+ int h = mi.rcMonitor.bottom - mi.rcMonitor.top;
+ int hz = GetMonitorRefreshRate(mi.szDevice);
+ bool isPrimary = (mi.dwFlags & 1) != 0;
+
+ var (tw, th) = ScaleToFit(w, h);
+ var pixels = CaptureScreenPixels(mi.rcMonitor.left, mi.rcMonitor.top, w, h, tw, th);
+
+ results.Add((new CaptureSource
+ {
+ Id = $"monitor-{index}",
+ Name = isPrimary ? "Primary Display" : $"Display {index + 1}",
+ Description = mi.szDevice.TrimEnd('\0'),
+ Resolution = hz > 0 ? $"{w}x{h} @ {hz} Hz" : $"{w}x{h}",
+ Kind = CaptureSourceKind.Screen,
+ }, pixels, tw, th));
+
+ index++;
+ return true;
+ };
+
+ Native.EnumDisplayMonitors(nint.Zero, nint.Zero, callback, nint.Zero);
+ GC.KeepAlive(callback);
+ return results;
+ }
+
+ private List<(CaptureSource source, byte[]? pixels, int tw, int th)> EnumerateWindows()
+ {
+ List<(CaptureSource, byte[]?, int, int)> results = [];
+ int ownPid = Environment.ProcessId;
+
+ Native.EnumWindowsProc callback = (nint hWnd, nint lParam) =>
+ {
+ if (!Native.IsWindowVisible(hWnd))
+ return true;
+
+ int exStyle = Native.GetWindowLongW(hWnd, -20);
+ if ((exStyle & 0x00000080) != 0)
+ return true;
+
+ if (Native.DwmGetWindowAttribute(hWnd, 14, out int cloaked, 4) == 0 && cloaked != 0)
+ return true;
+
+ int textLen = Native.GetWindowTextLengthW(hWnd);
+ if (textLen == 0)
+ return true;
+
+ var buf = new StringBuilder(textLen + 1);
+ Native.GetWindowTextW(hWnd, buf, buf.Capacity);
+ string title = buf.ToString();
+
+ Native.GetWindowThreadProcessId(hWnd, out uint pid);
+ if ((int)pid == ownPid)
+ return true;
+
+ var processName = "";
+ try
+ {
+ using var proc = Process.GetProcessById((int)pid);
+ processName = proc.ProcessName;
+ }
+ catch { /* process may have exited */ }
+
+ Native.GetWindowRect(hWnd, out var rect);
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ if (w <= 1 || h <= 1)
+ return true;
+
+ int hz = 0;
+ nint hMon = Native.MonitorFromWindow(hWnd, 2);
+ var monInfo = new Native.MONITORINFOEX();
+ monInfo.cbSize = Marshal.SizeOf();
+ if (Native.GetMonitorInfoW(hMon, ref monInfo))
+ hz = GetMonitorRefreshRate(monInfo.szDevice);
+
+ var (tw, th) = ScaleToFit(w, h);
+ var pixels = CaptureWindowPixels(hWnd, w, h, tw, th);
+
+ results.Add((new CaptureSource
+ {
+ Id = $"window-{hWnd}",
+ Name = title,
+ Description = processName,
+ Resolution = hz > 0 ? $"{w}x{h} @ {hz} Hz" : $"{w}x{h}",
+ Kind = CaptureSourceKind.Window,
+ Icon = ExtractWindowIcon(hWnd),
+ }, pixels, tw, th));
+
+ return true;
+ };
+
+ Native.EnumWindows(callback, nint.Zero);
+ GC.KeepAlive(callback);
+ return results;
+ }
+
+ private static async Task> EnumerateCamerasAsync()
+ {
+ try
+ {
+ var cameras = await FFmpegService.EnumerateCamerasAsync();
+ List results = [];
+
+ foreach (var (id, name) in cameras)
+ {
+ var source = new CaptureSource
+ {
+ Id = id,
+ Name = name,
+ Kind = CaptureSourceKind.Camera,
+ };
+
+ try
+ {
+ var thumbPath = Path.Combine(Path.GetTempPath(), $"elapsed-cam-{Guid.NewGuid():N}.jpg");
+ var grabbed = await FFmpegService.GrabCameraFrameAsync(id, thumbPath);
+ if (grabbed is not null)
+ {
+ using var codec = SkiaSharp.SKCodec.Create(thumbPath);
+ if (codec is not null)
+ source.Resolution = $"{codec.Info.Width}x{codec.Info.Height}";
+
+ source.Thumbnail = new BitmapImage(new Uri(thumbPath));
+ }
+ }
+ catch (Exception) { }
+
+ results.Add(source);
+ }
+
+ return results;
+ }
+ catch (Exception)
+ {
+ return [];
+ }
+ }
+
+ private static int GetMonitorRefreshRate(string deviceName)
+ {
+ var dm = new Native.DEVMODE();
+ dm.dmSize = (ushort)Marshal.SizeOf();
+ return Native.EnumDisplaySettingsW(deviceName, -1, ref dm)
+ ? (int)dm.dmDisplayFrequency
+ : 0;
+ }
+
+ private static byte[]? CaptureScreenPixels(int srcX, int srcY, int srcW, int srcH, int tw, int th)
+ {
+ nint hdcScreen = Native.GetDC(nint.Zero);
+ nint hdcMem = Native.CreateCompatibleDC(hdcScreen);
+ nint hBmp = Native.CreateCompatibleBitmap(hdcScreen, tw, th);
+ nint hOld = Native.SelectObject(hdcMem, hBmp);
+
+ Native.SetStretchBltMode(hdcMem, 4);
+ Native.StretchBlt(hdcMem, 0, 0, tw, th, hdcScreen, srcX, srcY, srcW, srcH, 0x00CC0020);
+
+ var pixels = ExtractPixels(hdcMem, hBmp, tw, th);
+
+ Native.SelectObject(hdcMem, hOld);
+ Native.DeleteObject(hBmp);
+ Native.DeleteDC(hdcMem);
+ Native.ReleaseDC(nint.Zero, hdcScreen);
+ return pixels;
+ }
+
+ private static byte[]? CaptureWindowPixels(nint hWnd, int winW, int winH, int tw, int th)
+ {
+ nint hdcScreen = Native.GetDC(nint.Zero);
+
+ nint hdcFull = Native.CreateCompatibleDC(hdcScreen);
+ nint hBmpFull = Native.CreateCompatibleBitmap(hdcScreen, winW, winH);
+ nint hOldFull = Native.SelectObject(hdcFull, hBmpFull);
+
+ Native.PrintWindow(hWnd, hdcFull, 2);
+
+ nint hdcThumb = Native.CreateCompatibleDC(hdcScreen);
+ nint hBmpThumb = Native.CreateCompatibleBitmap(hdcScreen, tw, th);
+ nint hOldThumb = Native.SelectObject(hdcThumb, hBmpThumb);
+
+ Native.SetStretchBltMode(hdcThumb, 4);
+ Native.StretchBlt(hdcThumb, 0, 0, tw, th, hdcFull, 0, 0, winW, winH, 0x00CC0020);
+
+ var pixels = ExtractPixels(hdcThumb, hBmpThumb, tw, th);
+
+ Native.SelectObject(hdcThumb, hOldThumb);
+ Native.DeleteObject(hBmpThumb);
+ Native.DeleteDC(hdcThumb);
+ Native.SelectObject(hdcFull, hOldFull);
+ Native.DeleteObject(hBmpFull);
+ Native.DeleteDC(hdcFull);
+ Native.ReleaseDC(nint.Zero, hdcScreen);
+ return pixels;
+ }
+
+ private static byte[]? ExtractPixels(nint hdc, nint hBitmap, int w, int h)
+ {
+ var bi = new Native.BITMAPINFOHEADER
+ {
+ biSize = 40,
+ biWidth = w,
+ biHeight = h,
+ biPlanes = 1,
+ biBitCount = 32,
+ biSizeImage = (uint)(w * h * 4),
+ };
+
+ var pixels = new byte[w * h * 4];
+ int result = Native.GetDIBits(hdc, hBitmap, 0, (uint)h, pixels, ref bi, 0);
+ if (result == 0)
+ return null;
+
+ for (int i = 3; i < pixels.Length; i += 4)
+ pixels[i] = 255;
+
+ return pixels;
+ }
+
+ private static BitmapImage? CreateThumbnail(byte[] bgraPixels, int width, int height)
+ {
+ try
+ {
+ var bmpData = EncodeBmp(bgraPixels, width, height);
+ var tempPath = Path.Combine(Path.GetTempPath(), $"elapsed-{Guid.NewGuid():N}.bmp");
+ File.WriteAllBytes(tempPath, bmpData);
+ return new BitmapImage(new Uri(tempPath));
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static BitmapImage? ExtractWindowIcon(nint hWnd)
+ {
+ try
+ {
+ var hIcon = Native.SendMessageW(hWnd, Native.WM_GETICON, 1 /* ICON_BIG */, nint.Zero);
+ if (hIcon == nint.Zero)
+ hIcon = Native.GetClassLongPtrW(hWnd, Native.GCL_HICON);
+ if (hIcon == nint.Zero)
+ hIcon = Native.SendMessageW(hWnd, Native.WM_GETICON, Native.ICON_SMALL2, nint.Zero);
+ if (hIcon == nint.Zero)
+ hIcon = Native.GetClassLongPtrW(hWnd, Native.GCL_HICONSM);
+ if (hIcon == nint.Zero)
+ return null;
+
+ if (Native.GetIconInfo(hIcon, out var iconInfo) == 0)
+ return null;
+
+ try
+ {
+ var colorBmp = iconInfo.hbmColor;
+ if (colorBmp == nint.Zero) return null;
+
+ Native.GetObjectW(colorBmp, Marshal.SizeOf(), out var bm);
+ int w = bm.bmWidth;
+ int h = bm.bmHeight;
+ if (w <= 0 || h <= 0) return null;
+
+ nint hdcScreen = Native.GetDC(nint.Zero);
+ nint hdcMem = Native.CreateCompatibleDC(hdcScreen);
+ nint hOld = Native.SelectObject(hdcMem, colorBmp);
+
+ var bi = new Native.BITMAPINFOHEADER
+ {
+ biSize = 40,
+ biWidth = w,
+ biHeight = -h,
+ biPlanes = 1,
+ biBitCount = 32,
+ biSizeImage = (uint)(w * h * 4),
+ };
+
+ var pixels = new byte[w * h * 4];
+ Native.GetDIBits(hdcMem, colorBmp, 0, (uint)h, pixels, ref bi, 0);
+
+ Native.SelectObject(hdcMem, hOld);
+ Native.DeleteDC(hdcMem);
+ Native.ReleaseDC(nint.Zero, hdcScreen);
+
+ var info = new SKImageInfo(w, h, SKColorType.Bgra8888, SKAlphaType.Unpremul);
+ using var bitmap = new SKBitmap(info);
+ unsafe
+ {
+ fixed (byte* ptr = pixels)
+ bitmap.InstallPixels(info, (nint)ptr, w * 4);
+
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Png, 100);
+ var path = Path.Combine(Path.GetTempPath(), $"elapsed-icon-{Guid.NewGuid():N}.png");
+ using (var stream = File.OpenWrite(path))
+ data.SaveTo(stream);
+ return new BitmapImage(new Uri(path));
+ }
+ }
+ finally
+ {
+ if (iconInfo.hbmColor != nint.Zero)
+ Native.DeleteObject(iconInfo.hbmColor);
+ if (iconInfo.hbmMask != nint.Zero)
+ Native.DeleteObject(iconInfo.hbmMask);
+ }
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static byte[] EncodeBmp(byte[] bgraPixels, int width, int height)
+ => BmpEncoder.Encode(bgraPixels, width, height);
+
+ private static (int w, int h) ScaleToFit(int srcW, int srcH)
+ => ScaleToFit(srcW, srcH, ThumbMaxWidth, ThumbMaxHeight);
+
+ private static (int w, int h) ScaleToFit(int srcW, int srcH, int maxW, int maxH)
+ {
+ double scale = Math.Min((double)maxW / srcW, (double)maxH / srcH);
+ if (scale > 1) scale = 1;
+ return (Math.Max(1, (int)(srcW * scale)), Math.Max(1, (int)(srcH * scale)));
+ }
+
+ private static class Native
+ {
+ public delegate bool MonitorEnumProc(nint hMonitor, nint hdcMonitor, ref RECT lprcMonitor, nint dwData);
+ public delegate bool EnumWindowsProc(nint hWnd, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern bool EnumDisplayMonitors(nint hdc, nint lprcClip, MonitorEnumProc lpfnEnum, nint dwData);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ public static extern bool GetMonitorInfoW(nint hMonitor, ref MONITORINFOEX lpmi);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ public static extern bool EnumDisplaySettingsW(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);
+
+ [DllImport("user32.dll")]
+ public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern bool IsWindowVisible(nint hWnd);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ public static extern int GetWindowTextLengthW(nint hWnd);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]
+ public static extern int GetWindowTextW(nint hWnd, StringBuilder lpString, int nMaxCount);
+
+ [DllImport("user32.dll")]
+ public static extern uint GetWindowThreadProcessId(nint hWnd, out uint lpdwProcessId);
+
+ [DllImport("user32.dll")]
+ public static extern bool GetWindowRect(nint hWnd, out RECT lpRect);
+
+ [DllImport("user32.dll")]
+ public static extern int GetWindowLongW(nint hWnd, int nIndex);
+
+ [DllImport("dwmapi.dll")]
+ public static extern int DwmGetWindowAttribute(nint hwnd, int dwAttribute, out int pvAttribute, int cbAttribute);
+
+ [DllImport("user32.dll")]
+ public static extern nint MonitorFromWindow(nint hwnd, uint dwFlags);
+
+ [DllImport("user32.dll")]
+ public static extern bool PrintWindow(nint hWnd, nint hdcBlt, uint nFlags);
+
+ [DllImport("user32.dll")]
+ public static extern nint GetDC(nint hWnd);
+
+ [DllImport("user32.dll")]
+ public static extern int ReleaseDC(nint hWnd, nint hDC);
+
+ [DllImport("gdi32.dll")]
+ public static extern nint CreateCompatibleDC(nint hdc);
+
+ [DllImport("gdi32.dll")]
+ public static extern nint CreateCompatibleBitmap(nint hdc, int cx, int cy);
+
+ [DllImport("gdi32.dll")]
+ public static extern nint SelectObject(nint hdc, nint h);
+
+ [DllImport("gdi32.dll")]
+ public static extern bool DeleteObject(nint ho);
+
+ [DllImport("gdi32.dll")]
+ public static extern bool DeleteDC(nint hdc);
+
+ [DllImport("gdi32.dll")]
+ public static extern int SetStretchBltMode(nint hdc, int mode);
+
+ [DllImport("gdi32.dll")]
+ public static extern bool StretchBlt(nint hdcDest, int xDest, int yDest, int wDest, int hDest, nint hdcSrc, int xSrc, int ySrc, int wSrc, int hSrc, uint rop);
+
+ [DllImport("gdi32.dll")]
+ public static extern int GetDIBits(nint hdc, nint hbm, uint start, uint cLines, byte[] lpvBits, ref BITMAPINFOHEADER lpbmi, uint usage);
+
+ [DllImport("avicap32.dll", CharSet = CharSet.Unicode)]
+ public static extern bool capGetDriverDescriptionW(uint wDriverIndex, StringBuilder lpszName, int cbName, StringBuilder lpszVer, int cbVer);
+
+ [DllImport("user32.dll")]
+ public static extern nint SendMessageW(nint hWnd, uint msg, nint wParam, nint lParam);
+
+ [DllImport("user32.dll")]
+ public static extern nint GetClassLongPtrW(nint hWnd, int nIndex);
+
+ [DllImport("user32.dll")]
+ public static extern bool DestroyIcon(nint hIcon);
+
+ [DllImport("user32.dll")]
+ public static extern int GetIconInfo(nint hIcon, out ICONINFO piconinfo);
+
+ [DllImport("gdi32.dll")]
+ public static extern int GetObjectW(nint h, int c, out BITMAP pv);
+
+ public const uint WM_GETICON = 0x007F;
+ public const nint ICON_SMALL2 = 2;
+ public const int GCL_HICONSM = -34;
+ public const int GCL_HICON = -14;
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct ICONINFO
+ {
+ public bool fIcon;
+ public int xHotspot, yHotspot;
+ public nint hbmMask, hbmColor;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct BITMAP
+ {
+ public int bmType, bmWidth, bmHeight, bmWidthBytes;
+ public ushort bmPlanes, bmBitsPixel;
+ public nint bmBits;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct RECT
+ {
+ public int left, top, right, bottom;
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ public struct MONITORINFOEX
+ {
+ public int cbSize;
+ public RECT rcMonitor;
+ public RECT rcWork;
+ public uint dwFlags;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
+ public string szDevice;
+ }
+
+ [StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode, Size = 220)]
+ public struct DEVMODE
+ {
+ [FieldOffset(68)]
+ public ushort dmSize;
+ [FieldOffset(184)]
+ public uint dmDisplayFrequency;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct BITMAPINFOHEADER
+ {
+ public uint biSize;
+ public int biWidth;
+ public int biHeight;
+ public ushort biPlanes;
+ public ushort biBitCount;
+ public uint biCompression;
+ public uint biSizeImage;
+ public int biXPelsPerMeter;
+ public int biYPelsPerMeter;
+ public uint biClrUsed;
+ public uint biClrImportant;
+ }
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Services/Upload/LapseService.cs b/src/platforms/Riverside.Elapsed.App/Services/Upload/LapseService.cs
new file mode 100644
index 0000000..0202cb8
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Services/Upload/LapseService.cs
@@ -0,0 +1,521 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Riverside.Elapsed.App.Services.Upload;
+
+public sealed class LapseService : IDisposable
+{
+ private const string BaseUrl = Riverside.Elapsed.Constants.Endpoint;
+ private const string UploadUrl = "https://api.lapse.hackclub.com/upload";
+ private const string DraftEditorBase = "https://lapse.hackclub.com/draft";
+ private const string ClientId = Riverside.Elapsed.Constants.ClientId;
+ private const string OAuthScopes = Riverside.Elapsed.Constants.OAuthScopes;
+ private const string RedirectUri = "http://localhost:8765/auth/callback";
+ private const int ChunkSize = 4 * 1024 * 1024;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ private const string WebPortalBase = "https://lapse.hackclub.com";
+
+ private readonly HttpClient _http = new();
+
+ private StoredAuth? _auth;
+ private StoredDevice? _device;
+
+ private static string DataDir => Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Riverside", "Elapsed");
+
+ private static string AuthPath => Path.Combine(DataDir, "auth.json");
+ private static string DevicePath => Path.Combine(DataDir, "device.json");
+
+ public bool IsAuthenticated => _auth is not null;
+
+ public async Task InitializeAsync(CancellationToken ct = default)
+ {
+ _auth = await LoadJsonAsync(AuthPath, ct);
+ _device = await LoadJsonAsync(DevicePath, ct);
+ }
+
+ public async Task SignInAsync(CancellationToken ct = default)
+ {
+ if (_auth is not null)
+ return;
+
+ _auth = await RunOAuthPkceAsync(ct);
+ await SaveJsonAsync(AuthPath, _auth, ct);
+ }
+
+ public Task SignOutAsync(CancellationToken ct = default)
+ {
+ _auth = null;
+ if (File.Exists(AuthPath))
+ File.Delete(AuthPath);
+ return Task.CompletedTask;
+ }
+
+ public async Task GetCurrentUserAsync(CancellationToken ct = default)
+ {
+ if (_auth is null)
+ return null;
+
+ using var req = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/user/myself");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _auth.AccessToken);
+
+ using var res = await _http.SendAsync(req, ct);
+ if (!res.IsSuccessStatusCode)
+ return null;
+
+ var json = await res.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(json);
+
+ if (!doc.RootElement.TryGetProperty("data", out var data))
+ return null;
+
+ var user = data.GetProperty("user");
+ if (user.ValueKind == JsonValueKind.Null)
+ return null;
+
+ return new UserProfile(
+ user.GetProperty("id").GetString()!,
+ user.GetProperty("handle").GetString()!,
+ user.GetProperty("displayName").GetString()!,
+ user.TryGetProperty("profilePictureUrl", out var pfp) ? pfp.GetString() : null);
+ }
+
+ public static void OpenProfileInBrowser(string handle)
+ {
+ OpenUrl($"{WebPortalBase}/user/@{handle}");
+ }
+
+ public async Task UploadDraftAsync(
+ string sessionFilePath,
+ byte[] thumbnailBytes,
+ TimeSpan duration,
+ IProgress? progress = null,
+ CancellationToken ct = default)
+ {
+ progress?.Report(new(UploadPhase.Authenticating, 0, "Signing in..."));
+ await EnsureAuthenticatedAsync(ct);
+
+ progress?.Report(new(UploadPhase.Authenticating, 0.5, "Registering device..."));
+ await EnsureDeviceRegisteredAsync(ct);
+
+ var sessionBytes = await File.ReadAllBytesAsync(sessionFilePath, ct);
+ var key = Convert.FromHexString(_device!.PasskeyHex);
+
+ long encryptedSessionSize = ComputeEncryptedSize(sessionBytes.Length);
+ long encryptedThumbnailSize = thumbnailBytes.Length > 0
+ ? ComputeEncryptedSize(thumbnailBytes.Length)
+ : ComputeEncryptedSize(1);
+
+ if (thumbnailBytes.Length == 0)
+ thumbnailBytes = [0];
+
+ var snapshots = GenerateSnapshots(duration);
+
+ progress?.Report(new(UploadPhase.CreatingDraft, 0, "Creating draft..."));
+ var draft = await CreateDraftAsync(
+ "Elapsed Recording",
+ snapshots,
+ _device.DeviceId,
+ encryptedSessionSize,
+ encryptedThumbnailSize,
+ ct);
+
+ var iv = Convert.FromHexString(draft.Iv);
+
+ progress?.Report(new(UploadPhase.Encrypting, 0, "Encrypting session..."));
+ var encryptedSession = EncryptAesCbc(sessionBytes, key, iv);
+
+ progress?.Report(new(UploadPhase.Encrypting, 0.5, "Encrypting thumbnail..."));
+ var encryptedThumbnail = EncryptAesCbc(thumbnailBytes, key, iv);
+
+ progress?.Report(new(UploadPhase.UploadingSession, 0, "Uploading session..."));
+ await TusUploadAsync(encryptedSession, draft.SessionUploadToken, p =>
+ progress?.Report(new(UploadPhase.UploadingSession, p, "Uploading session...")), ct);
+
+ progress?.Report(new(UploadPhase.UploadingThumbnail, 0, "Uploading thumbnail..."));
+ await TusUploadAsync(encryptedThumbnail, draft.ThumbnailUploadToken, p =>
+ progress?.Report(new(UploadPhase.UploadingThumbnail, p, "Uploading thumbnail...")), ct);
+
+ progress?.Report(new(UploadPhase.Complete, 1, "Done!"));
+ return draft.DraftId;
+ }
+
+ public async Task UpdateDraftAsync(
+ string draftId, string name, string? description,
+ CancellationToken ct = default)
+ {
+ using var req = new HttpRequestMessage(new HttpMethod("PATCH"), $"{BaseUrl}/draftTimelapse/update");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _auth!.AccessToken);
+
+ var changes = new Dictionary
+ {
+ ["name"] = name,
+ ["editList"] = (object[])[],
+ };
+ if (description is not null)
+ changes["description"] = description;
+
+ req.Content = new StringContent(
+ JsonSerializer.Serialize(new { id = draftId, changes }, JsonOptions),
+ Encoding.UTF8, "application/json");
+
+ using var res = await _http.SendAsync(req, ct);
+ if (!res.IsSuccessStatusCode)
+ {
+ var body = await res.Content.ReadAsStringAsync(ct);
+ throw new HttpRequestException($"Draft update failed ({res.StatusCode}): {body}");
+ }
+ }
+
+ public async Task PublishDraftAsync(
+ string draftId, string visibility,
+ CancellationToken ct = default)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/timelapse/publish");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _auth!.AccessToken);
+ req.Content = new StringContent(
+ JsonSerializer.Serialize(new
+ {
+ id = draftId,
+ visibility,
+ deviceKey = _device!.PasskeyHex,
+ }, JsonOptions),
+ Encoding.UTF8, "application/json");
+
+ using var res = await _http.SendAsync(req, ct);
+ var body = await res.Content.ReadAsStringAsync(ct);
+
+ if (!res.IsSuccessStatusCode)
+ throw new HttpRequestException($"Publish failed ({res.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ return doc.RootElement
+ .GetProperty("data")
+ .GetProperty("timelapse")
+ .GetProperty("id")
+ .GetString()!;
+ }
+
+ public static void OpenTimelapseInBrowser(string timelapseId)
+ => OpenUrl($"{WebPortalBase}/timelapse/{timelapseId}");
+
+ public static void OpenDraftInBrowser(string draftId)
+ => OpenUrl($"{DraftEditorBase}/{draftId}");
+
+ private static void OpenUrl(string url)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
+ }
+ catch (Exception) { }
+ }
+
+ private async Task EnsureAuthenticatedAsync(CancellationToken ct)
+ {
+ if (_auth is not null)
+ return;
+
+ _auth = await LoadJsonAsync(AuthPath, ct);
+ if (_auth is not null)
+ return;
+
+ _auth = await RunOAuthPkceAsync(ct);
+ await SaveJsonAsync(AuthPath, _auth, ct);
+ }
+
+ private async Task EnsureDeviceRegisteredAsync(CancellationToken ct)
+ {
+ if (_device is not null)
+ return;
+
+ _device = await LoadJsonAsync(DevicePath, ct);
+ if (_device is not null)
+ return;
+
+ var deviceName = Environment.MachineName;
+ using var req = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/user/registerDevice");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _auth!.AccessToken);
+ req.Content = new StringContent(
+ JsonSerializer.Serialize(new { name = deviceName }, JsonOptions),
+ Encoding.UTF8, "application/json");
+
+ using var res = await _http.SendAsync(req, ct);
+ res.EnsureSuccessStatusCode();
+
+ var json = await res.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(json);
+ var deviceId = doc.RootElement.GetProperty("data").GetProperty("device").GetProperty("id").GetString()!;
+
+ var passkey = new byte[16];
+ RandomNumberGenerator.Fill(passkey);
+
+ _device = new StoredDevice(deviceId, Convert.ToHexString(passkey).ToLowerInvariant());
+ await SaveJsonAsync(DevicePath, _device, ct);
+ }
+
+ private async Task RunOAuthPkceAsync(CancellationToken ct)
+ {
+ var (codeVerifier, codeChallenge) = GeneratePkceChallenge();
+ var state = GenerateRandomString(32);
+
+ var authorizeUrl = $"{BaseUrl}/auth/authorize" +
+ $"?client_id={Uri.EscapeDataString(ClientId)}" +
+ $"&redirect_uri={Uri.EscapeDataString(RedirectUri)}" +
+ $"&response_type=code" +
+ $"&scope={Uri.EscapeDataString(OAuthScopes)}" +
+ $"&state={Uri.EscapeDataString(state)}" +
+ $"&code_challenge={Uri.EscapeDataString(codeChallenge)}" +
+ $"&code_challenge_method=S256";
+
+ try
+ {
+ Process.Start(new ProcessStartInfo { FileName = authorizeUrl, UseShellExecute = true });
+ }
+ catch (Exception) { }
+
+ using var listener = new HttpListener();
+ listener.Prefixes.Add("http://localhost:8765/");
+ listener.Start();
+
+ try
+ {
+ var context = await listener.GetContextAsync().WaitAsync(ct);
+ var code = context.Request.QueryString["code"];
+ var returnedState = context.Request.QueryString["state"];
+ var error = context.Request.QueryString["error"];
+
+ if (!string.IsNullOrEmpty(error) || string.IsNullOrEmpty(code) || returnedState != state)
+ {
+ SendListenerResponse(context.Response, 400, error ?? "Authentication failed");
+ throw new InvalidOperationException($"OAuth failed: {error ?? "invalid response"}");
+ }
+
+ SendListenerResponse(context.Response, 200, "Authentication successful! You can close this window.");
+
+ return await ExchangeCodeForTokenAsync(code, codeVerifier, ct);
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+
+ private async Task ExchangeCodeForTokenAsync(string code, string codeVerifier, CancellationToken ct)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/auth/token");
+ req.Content = new StringContent(
+ JsonSerializer.Serialize(new
+ {
+ grant_type = "authorization_code",
+ code,
+ redirect_uri = RedirectUri,
+ client_id = ClientId,
+ code_verifier = codeVerifier,
+ }, JsonOptions),
+ Encoding.UTF8, "application/json");
+
+ using var res = await _http.SendAsync(req, ct);
+ res.EnsureSuccessStatusCode();
+
+ var json = await res.Content.ReadAsStringAsync(ct);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ return new StoredAuth(
+ root.GetProperty("access_token").GetString()!,
+ root.TryGetProperty("refresh_token", out var rt) ? rt.GetString() : null);
+ }
+
+ private async Task CreateDraftAsync(
+ string name, long[] snapshots, string deviceId,
+ long encryptedSessionSize, long encryptedThumbnailSize,
+ CancellationToken ct)
+ {
+ using var req = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/draftTimelapse/create");
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _auth!.AccessToken);
+ req.Content = new StringContent(
+ JsonSerializer.Serialize(new
+ {
+ name,
+ snapshots,
+ deviceId,
+ sessions = new[] { new { fileSize = encryptedSessionSize } },
+ thumbnailSize = encryptedThumbnailSize,
+ }, JsonOptions),
+ Encoding.UTF8, "application/json");
+
+ using var res = await _http.SendAsync(req, ct);
+ var body = await res.Content.ReadAsStringAsync(ct);
+
+ if (!res.IsSuccessStatusCode)
+ throw new HttpRequestException($"Draft creation failed ({res.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var data = doc.RootElement.GetProperty("data");
+ var draft = data.GetProperty("draftTimelapse");
+ var tokens = data.GetProperty("sessionUploadTokens");
+
+ return new DraftCreateResult(
+ draft.GetProperty("id").GetString()!,
+ draft.GetProperty("iv").GetString()!,
+ tokens[0].GetString()!,
+ data.GetProperty("thumbnailUploadToken").GetString()!);
+ }
+
+ private async Task TusUploadAsync(byte[] data, string uploadToken, Action? progress, CancellationToken ct)
+ {
+ using var createReq = new HttpRequestMessage(HttpMethod.Post, UploadUrl);
+ createReq.Headers.TryAddWithoutValidation("Tus-Resumable", "1.0.0");
+ createReq.Headers.TryAddWithoutValidation("Upload-Length", data.Length.ToString());
+ createReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", uploadToken);
+
+ using var createRes = await _http.SendAsync(createReq, ct);
+ var createBody = await createRes.Content.ReadAsStringAsync(ct);
+
+ if (!createRes.IsSuccessStatusCode)
+ throw new HttpRequestException($"TUS create failed ({createRes.StatusCode}): {createBody}");
+
+ var location = createRes.Headers.Location
+ ?? throw new InvalidOperationException("TUS create response missing Location header");
+
+ long offset = 0;
+ while (offset < data.Length)
+ {
+ var chunkSize = (int)Math.Min(ChunkSize, data.Length - offset);
+
+ using var patchReq = new HttpRequestMessage(new HttpMethod("PATCH"), location);
+ patchReq.Headers.TryAddWithoutValidation("Tus-Resumable", "1.0.0");
+ patchReq.Headers.TryAddWithoutValidation("Upload-Offset", offset.ToString());
+ patchReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", uploadToken);
+ patchReq.Content = new ByteArrayContent(data, (int)offset, chunkSize);
+ patchReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/offset+octet-stream");
+
+ using var patchRes = await _http.SendAsync(patchReq, ct);
+ if (!patchRes.IsSuccessStatusCode)
+ {
+ var patchBody = await patchRes.Content.ReadAsStringAsync(ct);
+ throw new HttpRequestException($"TUS upload failed ({patchRes.StatusCode}): {patchBody}");
+ }
+
+ if (patchRes.Headers.TryGetValues("Upload-Offset", out var offsets))
+ offset = long.Parse(offsets.First());
+ else
+ offset += chunkSize;
+
+ progress?.Invoke((double)offset / data.Length);
+ }
+ }
+
+ private static byte[] EncryptAesCbc(byte[] data, byte[] key, byte[] iv)
+ {
+ using var aes = Aes.Create();
+ aes.Mode = CipherMode.CBC;
+ aes.Padding = PaddingMode.PKCS7;
+ aes.Key = key;
+ aes.IV = iv;
+
+ using var encryptor = aes.CreateEncryptor();
+ return encryptor.TransformFinalBlock(data, 0, data.Length);
+ }
+
+ private static long ComputeEncryptedSize(long plainSize)
+ => ((plainSize / 16) + 1) * 16;
+
+ private static long[] GenerateSnapshots(TimeSpan duration)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var start = now - duration;
+ var count = Math.Max(1, (int)duration.TotalSeconds);
+ var snapshots = new long[count];
+ for (int i = 0; i < count; i++)
+ snapshots[i] = start.AddSeconds(i).ToUnixTimeMilliseconds();
+ return snapshots;
+ }
+
+ private static (string verifier, string challenge) GeneratePkceChallenge()
+ {
+ var verifier = GenerateRandomString(128);
+ var challengeBytes = SHA256.HashData(Encoding.UTF8.GetBytes(verifier));
+ var challenge = Convert.ToBase64String(challengeBytes)
+ .Replace("+", "-")
+ .Replace("/", "_")
+ .TrimEnd('=');
+ return (verifier, challenge);
+ }
+
+ private static string GenerateRandomString(int length)
+ {
+ const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ var buf = new byte[length];
+ RandomNumberGenerator.Fill(buf);
+ var result = new char[length];
+ for (int i = 0; i < length; i++)
+ result[i] = chars[buf[i] % chars.Length];
+ return new string(result);
+ }
+
+ private static void SendListenerResponse(HttpListenerResponse response, int statusCode, string body)
+ {
+ response.StatusCode = statusCode;
+ var buffer = Encoding.UTF8.GetBytes(body);
+ response.OutputStream.Write(buffer, 0, buffer.Length);
+ response.Close();
+ }
+
+ private static async Task LoadJsonAsync(string path, CancellationToken ct) where T : class
+ {
+ if (!File.Exists(path))
+ return null;
+
+ var json = await File.ReadAllTextAsync(path, ct);
+ return JsonSerializer.Deserialize(json, JsonOptions);
+ }
+
+ private static async Task SaveJsonAsync(string path, T value, CancellationToken ct)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ var json = JsonSerializer.Serialize(value, JsonOptions);
+ await File.WriteAllTextAsync(path, json, ct);
+ }
+
+ public void Dispose() => _http.Dispose();
+
+ private sealed record StoredAuth(
+ [property: JsonPropertyName("accessToken")] string AccessToken,
+ [property: JsonPropertyName("refreshToken")] string? RefreshToken);
+
+ private sealed record StoredDevice(
+ [property: JsonPropertyName("deviceId")] string DeviceId,
+ [property: JsonPropertyName("passkeyHex")] string PasskeyHex);
+
+ private sealed record DraftCreateResult(
+ string DraftId, string Iv, string SessionUploadToken, string ThumbnailUploadToken);
+}
+
+public sealed partial record UserProfile(string Id, string Handle, string DisplayName, string? ProfilePictureUrl);
+
+public sealed record UploadProgress(UploadPhase Phase, double Fraction, string Description);
+
+public enum UploadPhase
+{
+ Authenticating,
+ CreatingDraft,
+ Encrypting,
+ UploadingSession,
+ UploadingThumbnail,
+ Publishing,
+ Complete,
+}
diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs
deleted file mode 100644
index e74e101..0000000
--- a/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace Riverside.Elapsed.App.ViewModels;
-
-public partial class MainViewModel : ObservableObject
-{
-}
diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs
new file mode 100644
index 0000000..e1cf450
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs
@@ -0,0 +1,574 @@
+using System.Collections.ObjectModel;
+using System.Runtime.InteropServices.WindowsRuntime;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Imaging;
+using Riverside.Elapsed.App.Models.Recording;
+using Riverside.Elapsed.App.Services.Recording;
+using Riverside.Elapsed.App.Services.Upload;
+
+namespace Riverside.Elapsed.App.ViewModels;
+
+public sealed partial class RecordingViewModel : ObservableObject, IDisposable
+{
+ private const int ThumbMaxWidth = 480;
+ private const int ThumbMaxHeight = 300;
+
+ private readonly IRecordingFacade _recording;
+ private readonly ICaptureSourceProvider _sourceProvider;
+ private readonly LapseService _lapse;
+ private readonly DispatcherQueueTimer? _timer;
+ private readonly DispatcherQueueTimer? _previewTimer;
+ private readonly DispatcherQueueTimer? _sourceRefreshTimer;
+ private bool _previewUpdating;
+ private bool _sourceRefreshing;
+ private Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap? _previewBitmap;
+ private string? _currentDraftId;
+
+ [ObservableProperty]
+ private CaptureSourceKind _selectedSourceKind = CaptureSourceKind.Screen;
+
+ [ObservableProperty]
+ private CaptureSource? _selectedSource;
+
+ [ObservableProperty]
+ private RecordingPhase _phase = RecordingPhase.Setup;
+
+ [ObservableProperty]
+ private string _elapsedDisplay = "00:00:00";
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private ImageSource? _previewImage;
+
+ [ObservableProperty]
+ private double _uploadProgress;
+
+ [ObservableProperty]
+ private string? _uploadStatusText;
+
+ [ObservableProperty]
+ private string _publishTitle = "";
+
+ [ObservableProperty]
+ private string _publishDescription = "";
+
+ [ObservableProperty]
+ private int _publishVisibilityIndex;
+
+ [ObservableProperty]
+ private bool _isSignedIn;
+
+ [ObservableProperty]
+ private string? _userDisplayName;
+
+ [ObservableProperty]
+ private string? _userHandle;
+
+ [ObservableProperty]
+ private ImageSource? _userProfilePicture;
+
+ public RecordingViewModel(IRecordingFacade recording, ICaptureSourceProvider sourceProvider, LapseService lapse)
+ {
+ _recording = recording;
+ _sourceProvider = sourceProvider;
+ _lapse = lapse;
+ _recording.StateChanged += OnRecordingStateChanged;
+
+ StartRecordingCommand = new AsyncRelayCommand(StartRecordingAsync, CanStartRecording);
+ PauseResumeCommand = new AsyncRelayCommand(TogglePauseResumeAsync, () => Phase is RecordingPhase.Active or RecordingPhase.Paused);
+ StopCommand = new AsyncRelayCommand(StopAsync, () => Phase is RecordingPhase.Active or RecordingPhase.Paused);
+ PublishCommand = new AsyncRelayCommand(PublishAsync, () => Phase == RecordingPhase.Publishing);
+ SignInCommand = new AsyncRelayCommand(SignInAsync, () => !IsSignedIn);
+ SignOutCommand = new AsyncRelayCommand(SignOutAsync, () => IsSignedIn);
+ ViewProfileCommand = new RelayCommand(ViewProfile, () => IsSignedIn);
+
+ var dispatcher = DispatcherQueue.GetForCurrentThread();
+ if (dispatcher is not null)
+ {
+ _timer = dispatcher.CreateTimer();
+ _timer.Interval = TimeSpan.FromMilliseconds(500);
+ _timer.Tick += (_, _) => RefreshElapsed();
+
+ _previewTimer = dispatcher.CreateTimer();
+ _previewTimer.Interval = TimeSpan.FromSeconds(1);
+ _previewTimer.Tick += (_, _) => _ = RefreshPreviewAsync();
+
+ _sourceRefreshTimer = dispatcher.CreateTimer();
+ _sourceRefreshTimer.Interval = TimeSpan.FromMilliseconds(1500);
+ _sourceRefreshTimer.Tick += (_, _) => _ = RefreshSourcesInPlaceAsync();
+ }
+
+ _ = InitializeAsync();
+ }
+
+ public ObservableCollection CurrentSources { get; } = [];
+
+ public bool IsInSetup => Phase == RecordingPhase.Setup;
+
+ public bool IsActive => Phase is RecordingPhase.Active or RecordingPhase.Paused;
+
+ public bool IsEncoding => Phase == RecordingPhase.Encoding;
+
+ public bool IsUploading => Phase == RecordingPhase.Uploading;
+
+ public bool IsPublishing => Phase == RecordingPhase.Publishing;
+
+ public bool IsPaused => Phase == RecordingPhase.Paused;
+
+ public static string[] VisibilityOptions => ["Public", "Unlisted"];
+
+ public string PauseResumeLabel => Phase == RecordingPhase.Paused ? "Resume" : "Pause";
+
+ public string PauseResumeGlyph => Phase == RecordingPhase.Paused ? "" : "";
+
+ public IAsyncRelayCommand StartRecordingCommand { get; }
+
+ public IAsyncRelayCommand PauseResumeCommand { get; }
+
+ public IAsyncRelayCommand StopCommand { get; }
+
+ public IAsyncRelayCommand PublishCommand { get; }
+
+ public IAsyncRelayCommand SignInCommand { get; }
+
+ public IAsyncRelayCommand SignOutCommand { get; }
+
+ public IRelayCommand ViewProfileCommand { get; }
+
+ public event EventHandler? RecordingStarted;
+
+ public event EventHandler? RecordingStopped;
+
+ public event EventHandler? FocusRequested;
+
+ private bool CanStartRecording()
+ => Phase == RecordingPhase.Setup && SelectedSource is not null && IsSignedIn;
+
+ private async Task InitializeAsync()
+ {
+ await _lapse.InitializeAsync();
+ if (_lapse.IsAuthenticated)
+ await LoadUserProfileAsync();
+
+ _ = RefreshSourcesAsync();
+ }
+
+ private async Task LoadUserProfileAsync()
+ {
+ try
+ {
+ var profile = await _lapse.GetCurrentUserAsync();
+ if (profile is not null)
+ {
+ IsSignedIn = true;
+ UserDisplayName = profile.DisplayName;
+ UserHandle = profile.Handle;
+ if (profile.ProfilePictureUrl is not null)
+ UserProfilePicture = new BitmapImage(new Uri(profile.ProfilePictureUrl));
+ }
+ else
+ {
+ ClearUserState();
+ }
+ }
+ catch (Exception)
+ {
+ ClearUserState();
+ }
+
+ StartRecordingCommand.NotifyCanExecuteChanged();
+ SignInCommand.NotifyCanExecuteChanged();
+ SignOutCommand.NotifyCanExecuteChanged();
+ ViewProfileCommand.NotifyCanExecuteChanged();
+ }
+
+ private void ClearUserState()
+ {
+ IsSignedIn = false;
+ UserDisplayName = null;
+ UserHandle = null;
+ UserProfilePicture = null;
+ }
+
+ private async Task SignInAsync()
+ {
+ try
+ {
+ StatusMessage = null;
+ await _lapse.SignInAsync();
+ await LoadUserProfileAsync();
+ FocusRequested?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Sign in failed: {ex.Message}";
+ }
+ }
+
+ private async Task SignOutAsync()
+ {
+ await _lapse.SignOutAsync();
+ ClearUserState();
+ StartRecordingCommand.NotifyCanExecuteChanged();
+ SignInCommand.NotifyCanExecuteChanged();
+ SignOutCommand.NotifyCanExecuteChanged();
+ ViewProfileCommand.NotifyCanExecuteChanged();
+ }
+
+ private void ViewProfile()
+ {
+ if (UserHandle is not null)
+ LapseService.OpenProfileInBrowser(UserHandle);
+ }
+
+ partial void OnSelectedSourceKindChanged(CaptureSourceKind value)
+ {
+ _ = RefreshSourcesAsync();
+ }
+
+ partial void OnSelectedSourceChanged(CaptureSource? value)
+ {
+ StartRecordingCommand.NotifyCanExecuteChanged();
+ _ = RefreshPreviewAsync();
+ UpdatePreviewTimer();
+ }
+
+ partial void OnPhaseChanged(RecordingPhase value)
+ {
+ OnPropertyChanged(nameof(IsInSetup));
+ OnPropertyChanged(nameof(IsActive));
+ OnPropertyChanged(nameof(IsEncoding));
+ OnPropertyChanged(nameof(IsUploading));
+ OnPropertyChanged(nameof(IsPublishing));
+ OnPropertyChanged(nameof(IsPaused));
+ OnPropertyChanged(nameof(PauseResumeLabel));
+ OnPropertyChanged(nameof(PauseResumeGlyph));
+ StartRecordingCommand.NotifyCanExecuteChanged();
+ PauseResumeCommand.NotifyCanExecuteChanged();
+ StopCommand.NotifyCanExecuteChanged();
+ PublishCommand.NotifyCanExecuteChanged();
+ UpdatePreviewTimer();
+ }
+
+ private async Task RefreshSourcesAsync()
+ {
+ CurrentSources.Clear();
+ var sources = await _sourceProvider.GetSourcesAsync(SelectedSourceKind);
+ foreach (var source in sources)
+ CurrentSources.Add(source);
+ SelectedSource = null;
+ UpdateSourceRefreshTimer();
+ }
+
+ private async Task RefreshSourcesInPlaceAsync()
+ {
+ if (_sourceRefreshing || Phase != RecordingPhase.Setup)
+ return;
+
+ _sourceRefreshing = true;
+ try
+ {
+ var fresh = await _sourceProvider.GetSourcesAsync(SelectedSourceKind);
+ Dictionary existingById = [];
+ foreach (var s in CurrentSources)
+ existingById[s.Id] = s;
+
+ HashSet freshIds = [];
+ foreach (var src in fresh)
+ {
+ freshIds.Add(src.Id);
+ if (existingById.TryGetValue(src.Id, out var existing))
+ {
+ existing.Name = src.Name;
+ existing.Description = src.Description;
+ existing.Resolution = src.Resolution;
+ if (src.Icon is not null)
+ {
+ existing.Icon = src.Icon;
+ }
+ }
+ else
+ {
+ CurrentSources.Add(src);
+ }
+ }
+
+ for (int i = CurrentSources.Count - 1; i >= 0; i--)
+ {
+ if (!freshIds.Contains(CurrentSources[i].Id))
+ {
+ if (CurrentSources[i] == SelectedSource)
+ {
+ SelectedSource = null;
+ }
+ CurrentSources.RemoveAt(i);
+ }
+ }
+
+ foreach (var source in CurrentSources)
+ {
+ try
+ {
+ await _sourceProvider.RefreshThumbnailAsync(source, ThumbMaxWidth, ThumbMaxHeight);
+ }
+ catch (Exception) { }
+ }
+ }
+ catch (Exception) { }
+ finally
+ {
+ _sourceRefreshing = false;
+ }
+ }
+
+ private void UpdateSourceRefreshTimer()
+ {
+ if (_sourceRefreshTimer is null) return;
+
+ if (Phase == RecordingPhase.Setup)
+ _sourceRefreshTimer.Start();
+ else
+ _sourceRefreshTimer.Stop();
+ }
+
+ private void UpdatePreviewTimer()
+ {
+ if (_previewTimer is null) return;
+
+ if (SelectedSource is not null && Phase is RecordingPhase.Active or RecordingPhase.Paused)
+ _previewTimer.Start();
+ else
+ _previewTimer.Stop();
+
+ UpdateSourceRefreshTimer();
+ }
+
+ private async Task RefreshPreviewAsync()
+ {
+ if (_previewUpdating || SelectedSource is null)
+ return;
+
+ _previewUpdating = true;
+ try
+ {
+ CapturedFrame? frame = null;
+
+ if (SelectedSource.Kind == CaptureSourceKind.Camera && Phase is RecordingPhase.Active or RecordingPhase.Paused)
+ {
+ var framePath = _recording.GetLatestFramePath();
+ if (framePath is not null)
+ {
+ frame = await Task.Run(() =>
+ {
+ using var codec = SkiaSharp.SKCodec.Create(framePath);
+ if (codec is null) return null;
+ var info = codec.Info.WithColorType(SkiaSharp.SKColorType.Bgra8888).WithAlphaType(SkiaSharp.SKAlphaType.Premul);
+ var pixels = new byte[info.RowBytes * info.Height];
+ codec.GetPixels(info, System.Runtime.InteropServices.Marshal.UnsafeAddrOfPinnedArrayElement(pixels, 0));
+ return new CapturedFrame(pixels, info.Width, info.Height);
+ }).ConfigureAwait(true);
+ }
+ }
+ else
+ {
+ frame = await _sourceProvider.CaptureFrameAsync(SelectedSource).ConfigureAwait(true);
+ }
+
+ if (frame is not null)
+ BlitPreview(frame);
+ }
+ catch (Exception) { }
+ finally
+ {
+ _previewUpdating = false;
+ }
+ }
+
+ private void BlitPreview(CapturedFrame frame)
+ {
+ int w = frame.Width;
+ int h = frame.Height;
+
+ if (_previewBitmap is null || _previewBitmap.PixelWidth != w || _previewBitmap.PixelHeight != h)
+ {
+ _previewBitmap = new Microsoft.UI.Xaml.Media.Imaging.WriteableBitmap(w, h);
+ PreviewImage = _previewBitmap;
+ }
+
+ using var stream = _previewBitmap.PixelBuffer.AsStream();
+ if (frame.IsBottomUp)
+ {
+ int stride = w * 4;
+ for (int y = h - 1; y >= 0; y--)
+ stream.Write(frame.Pixels, y * stride, stride);
+ }
+ else
+ {
+ stream.Write(frame.Pixels, 0, w * h * 4);
+ }
+ _previewBitmap.Invalidate();
+ }
+
+ private async Task StartRecordingAsync()
+ {
+ try
+ {
+ _recording.SetSource(SelectedSource!);
+ await _recording.StartAsync().ConfigureAwait(true);
+ Phase = RecordingPhase.Active;
+ _timer?.Start();
+ _ = RefreshPreviewAsync();
+ RecordingStarted?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to start: {ex.Message}";
+ }
+ }
+
+ private async Task TogglePauseResumeAsync()
+ {
+ try
+ {
+ if (Phase == RecordingPhase.Paused)
+ {
+ await _recording.ResumeAsync().ConfigureAwait(true);
+ Phase = RecordingPhase.Active;
+ _timer?.Start();
+ }
+ else
+ {
+ await _recording.PauseAsync().ConfigureAwait(true);
+ Phase = RecordingPhase.Paused;
+ _timer?.Stop();
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed: {ex.Message}";
+ }
+ }
+
+ private async Task StopAsync()
+ {
+ try
+ {
+ byte[]? thumbnailBytes = null;
+ if (SelectedSource is not null)
+ {
+ try
+ {
+ thumbnailBytes = await _sourceProvider.CapturePreviewBytesAsync(SelectedSource, 640, 480);
+ }
+ catch (Exception) { }
+ }
+
+ _timer?.Stop();
+ _previewTimer?.Stop();
+ _previewBitmap = null;
+
+ Phase = RecordingPhase.Encoding;
+ RecordingStopped?.Invoke(this, EventArgs.Empty);
+
+ var result = await _recording.StopAsync().ConfigureAwait(true);
+
+ if (result.FilePath is null)
+ {
+ Phase = RecordingPhase.Setup;
+ ElapsedDisplay = "00:00:00";
+ return;
+ }
+
+ Phase = RecordingPhase.Uploading;
+
+ try
+ {
+ var progress = new Progress(p =>
+ {
+ UploadProgress = p.Fraction;
+ UploadStatusText = p.Description;
+ });
+
+ var draftId = await _lapse.UploadDraftAsync(
+ result.FilePath,
+ thumbnailBytes ?? [],
+ result.Duration,
+ progress);
+
+ _currentDraftId = draftId;
+ PublishTitle = "";
+ PublishDescription = "";
+ PublishVisibilityIndex = 0;
+ StatusMessage = null;
+ Phase = RecordingPhase.Publishing;
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Upload failed: {ex.Message}";
+ Phase = RecordingPhase.Setup;
+ ElapsedDisplay = "00:00:00";
+ UploadProgress = 0;
+ UploadStatusText = null;
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Failed to stop: {ex.Message}";
+ }
+ }
+
+ private async Task PublishAsync()
+ {
+ if (_currentDraftId is null) return;
+
+ var title = string.IsNullOrWhiteSpace(PublishTitle) ? "Untitled Timelapse" : PublishTitle.Trim();
+ var description = string.IsNullOrWhiteSpace(PublishDescription) ? null : PublishDescription.Trim();
+ var visibility = PublishVisibilityIndex == 0 ? "PUBLIC" : "UNLISTED";
+
+ StatusMessage = null;
+
+ try
+ {
+ UploadStatusText = "Updating draft...";
+ await _lapse.UpdateDraftAsync(_currentDraftId, title, description);
+
+ UploadStatusText = "Publishing...";
+ var timelapseId = await _lapse.PublishDraftAsync(_currentDraftId, visibility);
+
+ LapseService.OpenTimelapseInBrowser(timelapseId);
+
+ _currentDraftId = null;
+ Phase = RecordingPhase.Setup;
+ ElapsedDisplay = "00:00:00";
+ UploadProgress = 0;
+ UploadStatusText = null;
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Publish failed: {ex.Message}";
+ UploadStatusText = null;
+ }
+ }
+
+ private void OnRecordingStateChanged(object? sender, EventArgs e)
+ {
+ RefreshElapsed();
+ }
+
+ private void RefreshElapsed()
+ {
+ var elapsed = _recording.Duration;
+ ElapsedDisplay = elapsed.ToString(@"hh\:mm\:ss");
+ }
+
+ public void Dispose()
+ {
+ _timer?.Stop();
+ _previewTimer?.Stop();
+ _sourceRefreshTimer?.Stop();
+ _recording.StateChanged -= OnRecordingStateChanged;
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs
deleted file mode 100644
index c2d75d6..0000000
--- a/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-namespace Riverside.Elapsed.App.ViewModels;
-
-public sealed partial class ShellViewModel : ObservableObject
-{
-}
diff --git a/src/platforms/Riverside.Elapsed.App/Views/LoginPage.xaml.cs b/src/platforms/Riverside.Elapsed.App/Views/LoginPage.xaml.cs
index 75a2068..859accf 100644
--- a/src/platforms/Riverside.Elapsed.App/Views/LoginPage.xaml.cs
+++ b/src/platforms/Riverside.Elapsed.App/Views/LoginPage.xaml.cs
@@ -1,3 +1,4 @@
+#if false
namespace Riverside.Elapsed.App.Views;
public sealed partial class LoginPage : Page
@@ -7,3 +8,5 @@ public LoginPage()
this.InitializeComponent();
}
}
+
+#endif
diff --git a/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml b/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml
deleted file mode 100644
index b0c652c..0000000
--- a/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml.cs b/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml.cs
deleted file mode 100644
index 2185b0e..0000000
--- a/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace Riverside.Elapsed.App.Views;
-
-public sealed partial class MainPage : Page
-{
- public MainPage()
- {
- this.InitializeComponent();
- this.Loaded += OnLoaded;
- }
-
- private async void OnLoaded(object sender, RoutedEventArgs e)
- {
- if (DataContext is ViewModels.MainViewModel vm)
- {
- await vm.InitializeAsync();
- }
- }
-}
diff --git a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml
new file mode 100644
index 0000000..aac0a68
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml
@@ -0,0 +1,515 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs
new file mode 100644
index 0000000..2298a9a
--- /dev/null
+++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs
@@ -0,0 +1,137 @@
+using Microsoft.UI.Dispatching;
+using Riverside.Elapsed.App.Models.Recording;
+using Riverside.Elapsed.App.ViewModels;
+
+namespace Riverside.Elapsed.App.Views;
+
+public sealed partial class RecordingPage : Page
+{
+ private const int MinCompactWidth = 380;
+ private const int MinHeight = 520;
+
+ private Border? _selectedCard;
+ private DispatcherQueueTimer? _resizeDebounce;
+ private double _pendingWidth;
+
+ public RecordingPage()
+ {
+ this.InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ SetWindowMinSize(MinCompactWidth, MinHeight);
+
+ if (DataContext is RecordingViewModel vm)
+ {
+ vm.RecordingStarted += OnRecordingStarted;
+ vm.RecordingStopped += OnRecordingStopped;
+ vm.FocusRequested += OnFocusRequested;
+
+ ScreenRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Screen;
+ WindowRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Window;
+ CameraRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Camera;
+ }
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is RecordingViewModel vm)
+ {
+ vm.RecordingStarted -= OnRecordingStarted;
+ vm.RecordingStopped -= OnRecordingStopped;
+ vm.FocusRequested -= OnFocusRequested;
+ }
+ }
+
+ private void OnSourceCardPressed(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e)
+ {
+ if (sender is not Border card)
+ return;
+
+ if (card.DataContext is CaptureSource source && DataContext is RecordingViewModel vm)
+ {
+ vm.SelectedSource = source;
+ }
+
+ if (_selectedCard is not null)
+ {
+ _selectedCard.BorderBrush = (Microsoft.UI.Xaml.Media.Brush)Resources["ControlStrokeColorDefaultBrush"]
+ ?? Application.Current.Resources["ControlStrokeColorDefaultBrush"] as Microsoft.UI.Xaml.Media.Brush;
+ }
+
+ card.BorderBrush = Application.Current.Resources["AccentFillColorDefaultBrush"] as Microsoft.UI.Xaml.Media.Brush;
+ card.BorderThickness = new Thickness(2);
+ if (_selectedCard is not null && _selectedCard != card)
+ {
+ _selectedCard.BorderThickness = new Thickness(1);
+ }
+
+ _selectedCard = card;
+ }
+
+ private void OnFocusRequested(object? sender, EventArgs e)
+ {
+ var window = App.MainWindow;
+ if (window is null) return;
+
+ window.Activate();
+ }
+
+ private void OnRecordingStarted(object? sender, EventArgs e)
+ {
+ PreviewColumn.Width = new GridLength(1, GridUnitType.Star);
+ }
+
+ private void OnRecordingStopped(object? sender, EventArgs e)
+ {
+ PreviewColumn.Width = new GridLength(0);
+ }
+
+ private void OnSourceScrollViewerSizeChanged(object sender, SizeChangedEventArgs e)
+ {
+ _pendingWidth = e.NewSize.Width;
+
+ if (_resizeDebounce is null)
+ {
+ var dispatcher = DispatcherQueue.GetForCurrentThread();
+ if (dispatcher is null) return;
+ _resizeDebounce = dispatcher.CreateTimer();
+ _resizeDebounce.Interval = TimeSpan.FromMilliseconds(200);
+ _resizeDebounce.IsRepeating = false;
+ _resizeDebounce.Tick += (_, _) => ApplyTileSize(_pendingWidth);
+ }
+
+ _resizeDebounce.Stop();
+ _resizeDebounce.Start();
+ }
+
+ private void ApplyTileSize(double available)
+ {
+ const double spacing = 6;
+ const double minTileWidth = 158;
+ const double maxTileWidth = 280;
+ const double aspectRatio = 148.0 / 158.0;
+
+ int columns = Math.Max(1, (int)((available + spacing) / (minTileWidth + spacing)));
+ double tileWidth = (available - spacing * (columns - 1)) / columns;
+ tileWidth = Math.Clamp(tileWidth, minTileWidth, maxTileWidth);
+
+ SourceGridLayout.MinItemWidth = tileWidth;
+ SourceGridLayout.MinItemHeight = tileWidth * aspectRatio;
+ }
+
+ private static void SetWindowMinSize(int width, int height)
+ {
+ var window = App.MainWindow;
+ if (window is null) return;
+
+ var scale = window.Content?.XamlRoot?.RasterizationScale ?? 1.0;
+ var scaledWidth = (int)(width * scale);
+ var scaledHeight = (int)(height * scale);
+
+ window.AppWindow.Resize(new Windows.Graphics.SizeInt32 { Width = scaledWidth, Height = scaledHeight });
+ }
+}
diff --git a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml b/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml
deleted file mode 100644
index 39327c7..0000000
--- a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml
+++ /dev/null
@@ -1,255 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs b/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs
deleted file mode 100644
index 60e2162..0000000
--- a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace Riverside.Elapsed.App.Views;
-
-public sealed partial class Shell : UserControl, IContentControlProvider
-{
- public Shell()
- {
- this.InitializeComponent();
- }
-
- public ContentControl ContentControl => Splash;
-}