From 2a8cbaa7ec30b790d9cd494277b23c7820db60d7 Mon Sep 17 00:00:00 2001 From: ascpixi <44982772+ascpixi@users.noreply.github.com> Date: Sun, 31 May 2026 12:56:39 -0400 Subject: [PATCH 01/13] Restore deleted files and fix references for successful build --- .../Converters/BoolToVisibilityConverter.cs | 17 ++ .../InverseBoolToVisibilityConverter.cs | 17 ++ .../Json/TimeSpanSecondsJsonConverter.cs | 31 ++++ .../Converters/Json/UriJsonConverter.cs | 21 +++ .../Services/Auth/AuthTokenStore.cs | 31 ++++ .../Services/Auth/IAuthTokenStore.cs | 13 ++ .../Services/Build/BuildInfoProvider.cs | 30 ++++ .../Services/Build/IBuildInfoProvider.cs | 6 + .../DraftsServiceCollectionExtensions.cs | 12 ++ .../Services/Drafts/ILocalDraftRepository.cs | 12 ++ .../Services/Drafts/LocalDraftRepository.cs | 57 ++++++ .../Services/Endpoints/DebugHandler.cs | 44 +++++ .../Recording/FacadeRecordingResult.cs | 8 + .../Recording/FacadeRecordingState.cs | 18 ++ .../Services/Recording/IRecordingFacade.cs | 36 ++++ .../Services/Recording/NoOpRecordingFacade.cs | 65 +++++++ .../Recording/WindowsRecordingFacade.cs | 154 +++++++++++++++++ .../Services/Storage/ILocalJsonStore.cs | 9 + .../Services/Storage/LocalJsonStore.cs | 66 +++++++ .../ExploreTimelapsesControl.xaml | 51 ++++++ .../ExploreTimelapsesControl.xaml.cs | 9 + .../UserControls/LeaderboardControl.xaml | 64 +++++++ .../UserControls/LeaderboardControl.xaml.cs | 9 + .../UserControls/TimelapsePlayerControl.xaml | 17 ++ .../TimelapsePlayerControl.xaml.cs | 9 + .../ViewModels/CommentViewModel.cs | 22 +++ .../ViewModels/LeaderboardEntryViewModel.cs | 40 +++++ .../ViewModels/TimelapseCardViewModel.cs | 88 ++++++++++ .../Drafts/DraftDetailsViewModel.cs | 163 ++++++++++++++++++ .../Timelapses/Drafts/DraftListItem.cs | 3 + .../Drafts/DraftListItemViewModel.cs | 19 ++ .../Timelapses/Drafts/DraftsViewModel.cs | 134 ++++++++++++++ 32 files changed, 1275 insertions(+) create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/BoolToVisibilityConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/InverseBoolToVisibilityConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingResult.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/FacadeRecordingState.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/IRecordingFacade.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpRecordingFacade.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsRecordingFacade.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs create mode 100644 src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml create mode 100644 src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs create mode 100644 src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml create mode 100644 src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs create mode 100644 src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml create mode 100644 src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs diff --git a/src/platforms/Riverside.Elapsed.App/Converters/BoolToVisibilityConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..db13a48 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/BoolToVisibilityConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Riverside.Elapsed.App.Converters; + +public sealed class BoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is true ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + return value is Visibility visibility && visibility == Visibility.Visible; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Converters/InverseBoolToVisibilityConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/InverseBoolToVisibilityConverter.cs new file mode 100644 index 0000000..b5f9f00 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/InverseBoolToVisibilityConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Riverside.Elapsed.App.Converters; + +public sealed class InverseBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is true ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + return value is Visibility visibility && visibility != Visibility.Visible; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs new file mode 100644 index 0000000..28688a4 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Riverside.Elapsed.App.Converters.Json; + +public sealed class TimeSpanSecondsJsonConverter : JsonConverter +{ + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetDouble(out var seconds)) + return TimeSpan.FromSeconds(seconds); + + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var sec)) + return TimeSpan.FromMilliseconds(sec); + } + + throw new JsonException("Invalid timespan value."); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.TotalSeconds); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs new file mode 100644 index 0000000..16c156e --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Riverside.Elapsed.App.Converters.Json; + +public sealed class UriJsonConverter : JsonConverter +{ + public override Uri? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString(); + if (string.IsNullOrWhiteSpace(s)) + return new Uri("about:blank"); + + return new Uri(s, UriKind.Absolute); + } + + public override void Write(Utf8JsonWriter writer, Uri value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs new file mode 100644 index 0000000..125ff38 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs @@ -0,0 +1,31 @@ +using Riverside.Elapsed.App.Models.Auth; +using Riverside.Elapsed.App.Services.Storage; + +namespace Riverside.Elapsed.App.Services.Auth; + +public sealed class AuthTokenStore(ILocalJsonStore store) : IAuthTokenStore +{ + private const string TokenPath = "auth\\oauth-token.json"; + private OAuthToken? _token; + + public string? AccessToken => _token?.AccessToken; + + public bool HasToken => !string.IsNullOrWhiteSpace(_token?.AccessToken); + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + _token = await store.ReadAsync(TokenPath, cancellationToken); + } + + public async Task SetTokenAsync(OAuthToken token, CancellationToken cancellationToken = default) + { + _token = token; + await store.WriteAsync(TokenPath, token, cancellationToken); + } + + public async Task ClearAsync(CancellationToken cancellationToken = default) + { + _token = null; + await store.DeleteAsync(TokenPath, cancellationToken); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs new file mode 100644 index 0000000..f872f8d --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs @@ -0,0 +1,13 @@ +using Riverside.Elapsed.App.Models.Auth; + +namespace Riverside.Elapsed.App.Services.Auth; + +public interface IAuthTokenStore +{ + string? AccessToken { get; } + bool HasToken { get; } + + Task InitializeAsync(CancellationToken cancellationToken = default); + Task SetTokenAsync(OAuthToken token, CancellationToken cancellationToken = default); + Task ClearAsync(CancellationToken cancellationToken = default); +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs new file mode 100644 index 0000000..68249ff --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs @@ -0,0 +1,30 @@ +using System.Globalization; + +namespace Riverside.Elapsed.App.Services.Build; + +public sealed class BuildInfoProvider : IBuildInfoProvider +{ + public BuildInfo GetBuildInfo() + { + 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}"; + + return new BuildInfo + { + DisplayVersion = versionText, + BuildTimestamp = timestamp, + FullFooterText = full, + WebFooterText = compact, + }; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs new file mode 100644 index 0000000..0173b43 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs @@ -0,0 +1,6 @@ +namespace Riverside.Elapsed.App.Services.Build; + +public interface IBuildInfoProvider +{ + BuildInfo GetBuildInfo(); +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs b/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs new file mode 100644 index 0000000..0400925 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +using Riverside.Elapsed.App.Services.Drafts; + +namespace Riverside.Elapsed.App.Extensions; + +public static class DraftsServiceCollectionExtensions +{ + public static IServiceCollection AddDrafts(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs b/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs new file mode 100644 index 0000000..6f2580e --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs @@ -0,0 +1,12 @@ +using Riverside.Elapsed.App.Models.Timelapses.Local; + +namespace Riverside.Elapsed.App.Services.Drafts; + +public interface ILocalDraftRepository +{ + Task GetIndexAsync(CancellationToken ct = default); + Task GetDraftAsync(Guid localDraftId, CancellationToken ct = default); + + Task SaveDraftAsync(LocalDraft draft, CancellationToken ct = default); + Task DeleteDraftAsync(Guid localDraftId, CancellationToken ct = default); +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs b/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs new file mode 100644 index 0000000..55c1a18 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs @@ -0,0 +1,57 @@ +using Riverside.Elapsed.App.Models.Timelapses.Local; +using Riverside.Elapsed.App.Services.Storage; + +namespace Riverside.Elapsed.App.Services.Drafts; + +public sealed class LocalDraftRepository(ILocalJsonStore store) : ILocalDraftRepository +{ + private const string DraftsDir = "drafts"; + private static readonly string IndexPath = Path.Combine(DraftsDir, "index.json"); + + private static string DraftPath(Guid id) => Path.Combine(DraftsDir, $"{id:D}.json"); + + public async Task GetIndexAsync(CancellationToken ct = default) + { + return await store.ReadAsync(IndexPath, ct).ConfigureAwait(false) + ?? new LocalDraftIndex(); + } + + public Task GetDraftAsync(Guid localDraftId, CancellationToken ct = default) + => store.ReadAsync(DraftPath(localDraftId), ct); + + public async Task SaveDraftAsync(LocalDraft draft, CancellationToken ct = default) + { + await store.WriteAsync(DraftPath(draft.LocalDraftId), draft, ct).ConfigureAwait(false); // persist draft contents + + var index = await GetIndexAsync(ct).ConfigureAwait(false); + var newItem = new LocalDraftIndexItem + { + LocalDraftId = draft.LocalDraftId, + Name = draft.Name, + LastModifiedAt = draft.LastModifiedAt, + HasRemoteDraft = draft.Remote is not null, + RemoteDraftTimelapseId = draft.Remote?.DraftTimelapseId, + }; + + var updated = index.Drafts // replace existing & sort newest first + .Where(x => x.LocalDraftId != draft.LocalDraftId) + .Append(newItem) + .OrderByDescending(x => x.LastModifiedAt) + .ToArray(); + + await store.WriteAsync(IndexPath, index with { Drafts = updated }, ct).ConfigureAwait(false); + } + + public async Task DeleteDraftAsync(Guid localDraftId, CancellationToken ct = default) + { + await store.DeleteAsync(DraftPath(localDraftId), ct).ConfigureAwait(false); + + var index = await GetIndexAsync(ct).ConfigureAwait(false); + var updated = index.Drafts.Where(x => x.LocalDraftId != localDraftId).ToArray(); + + if (updated.Length == index.Drafts.Count) + return; + + await store.WriteAsync(IndexPath, index with { Drafts = updated }, ct).ConfigureAwait(false); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs b/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs new file mode 100644 index 0000000..f5680ca --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs @@ -0,0 +1,44 @@ +namespace Riverside.Elapsed.App.Services.Endpoints; + +internal class DebugHttpHandler : DelegatingHandler +{ + private readonly ILogger _logger; + + public DebugHttpHandler(ILogger logger, HttpMessageHandler? innerHandler = null) + : base(innerHandler ?? new HttpClientHandler()) + { + _logger = logger; + } + + protected async override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var response = await base.SendAsync(request, cancellationToken); +#if DEBUG + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("Unsuccessful API Call"); + if (request.RequestUri is not null) + { + _logger.LogDebug($"{request.RequestUri} ({request.Method})"); + } + + foreach ((var key, var values) in request.Headers.ToDictionary(x => x.Key, x => string.Join(", ", x.Value))) + { + _logger.LogDebug($"{key}: {values}"); + } + + var content = request.Content is not null ? await request.Content.ReadAsStringAsync() : null; + if (!string.IsNullOrEmpty(content)) + { + _logger.LogDebug(content); + } + + // Uncomment to automatically break when an API call fails while debugging + // System.Diagnostics.Debugger.Break(); + } +#endif + return response; + } +} 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/IRecordingFacade.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/IRecordingFacade.cs new file mode 100644 index 0000000..59954e3 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/IRecordingFacade.cs @@ -0,0 +1,36 @@ +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; + + /// 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/NoOpRecordingFacade.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpRecordingFacade.cs new file mode 100644 index 0000000..2303019 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpRecordingFacade.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; + +namespace Riverside.Elapsed.App.Services.Recording; + +/// +/// No-op recording facade used on web/mobile heads where local capture is not yet +/// implemented. Tracks lifecycle so the UI can still demonstrate the pause/resume/stop flow. +/// +internal sealed class NoOpRecordingFacade : IRecordingFacade +{ + private readonly Stopwatch _stopwatch = new(); + private FacadeRecordingState _state = FacadeRecordingState.Unsupported; + + public FacadeRecordingState State => _state; + + public TimeSpan Duration => _stopwatch.Elapsed; + + public string? SourceName => null; + + public bool IsSupported => false; + + public event EventHandler? StateChanged; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + _state = FacadeRecordingState.Recording; + _stopwatch.Restart(); + Notify(); + return Task.CompletedTask; + } + + public Task PauseAsync(CancellationToken cancellationToken = default) + { + if (_state == FacadeRecordingState.Recording) + { + _stopwatch.Stop(); + _state = FacadeRecordingState.Paused; + Notify(); + } + + return Task.CompletedTask; + } + + public Task ResumeAsync(CancellationToken cancellationToken = default) + { + if (_state == FacadeRecordingState.Paused) + { + _stopwatch.Start(); + _state = FacadeRecordingState.Recording; + Notify(); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + _stopwatch.Stop(); + _state = FacadeRecordingState.Stopped; + Notify(); + return Task.FromResult(new FacadeRecordingResult(null, _stopwatch.Elapsed)); + } + + private void Notify() => StateChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsRecordingFacade.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsRecordingFacade.cs new file mode 100644 index 0000000..7e9baa8 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsRecordingFacade.cs @@ -0,0 +1,154 @@ +#if HAS_MEDIA_RECORDING +using System.Diagnostics; +using OwlCore.Storage.System.IO; +using Riverside.MediaRecording; +using Riverside.MediaRecording.Windows; + +namespace Riverside.Elapsed.App.Services.Recording; + +/// +/// Windows-backed recording facade that delegates screen capture to +/// . Targets the primary display and persists frames to +/// a per-session file inside %LOCALAPPDATA%\Riverside\Elapsed\recordings. +/// +internal sealed class WindowsRecordingFacade : IRecordingFacade, IAsyncDisposable +{ + private readonly WindowsScreenCapture _capture = new(); + private readonly Stopwatch _stopwatch = new(); + private readonly SemaphoreSlim _gate = new(1, 1); + + private IVideoCaptureSession? _session; + private string? _activeOutputPath; + private FacadeRecordingState _state = FacadeRecordingState.Idle; + + public FacadeRecordingState State => _state; + + public TimeSpan Duration => _stopwatch.Elapsed; + + public string? SourceName { get; private set; } = "Primary display"; + + public bool IsSupported => true; + + public event EventHandler? StateChanged; + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_session is not null) return; + + var primary = _capture.Sources.FirstOrDefault(s => s.DeviceType == DeviceType.Display); + if (primary.Id == Guid.Empty) + { + throw new InvalidOperationException("No display source available for capture."); + } + + SourceName = primary.Name; + + var directory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Riverside", "Elapsed", "recordings"); + Directory.CreateDirectory(directory); + + _activeOutputPath = Path.Combine(directory, $"timelapse-{DateTime.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.zip"); + + // create the file so OwlCore can open it for writing. + using (File.Create(_activeOutputPath)) { } + + var outputFile = new SystemFile(_activeOutputPath); + _session = await _capture.CreateRecordingSessionAsync(primary, outputFile: outputFile, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + await _session.StartAsync(cancellationToken).ConfigureAwait(false); + _stopwatch.Restart(); + _state = FacadeRecordingState.Recording; + Notify(); + } + finally + { + _gate.Release(); + } + } + + public async Task PauseAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_session is null || _state != FacadeRecordingState.Recording) return; + await _session.PauseAsync(cancellationToken).ConfigureAwait(false); + _stopwatch.Stop(); + _state = FacadeRecordingState.Paused; + Notify(); + } + finally + { + _gate.Release(); + } + } + + public async Task ResumeAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_session is null || _state != FacadeRecordingState.Paused) return; + await _session.ResumeAsync(cancellationToken).ConfigureAwait(false); + _stopwatch.Start(); + _state = FacadeRecordingState.Recording; + Notify(); + } + finally + { + _gate.Release(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_session is null) + { + return new FacadeRecordingResult(null, TimeSpan.Zero); + } + + var captured = await _session.StopAsync(cancellationToken).ConfigureAwait(false); + _stopwatch.Stop(); + _state = FacadeRecordingState.Stopped; + var path = _activeOutputPath; + var duration = captured.Duration != TimeSpan.Zero ? captured.Duration : _stopwatch.Elapsed; + _session = null; + _activeOutputPath = null; + Notify(); + return new FacadeRecordingResult(path, duration); + } + finally + { + _gate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + try + { + if (_session is not null) + { + await _session.StopAsync().ConfigureAwait(false); + } + } + catch + { + // swallow: dispose is best-effort. + } + + await _capture.DisposeAsync().ConfigureAwait(false); + _gate.Dispose(); + } + + private void Notify() => StateChanged?.Invoke(this, EventArgs.Empty); +} +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs new file mode 100644 index 0000000..e3fb216 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs @@ -0,0 +1,9 @@ +namespace Riverside.Elapsed.App.Services.Storage; + +public interface ILocalJsonStore +{ + Task ReadAsync(string relativePath, CancellationToken ct = default); + Task WriteAsync(string relativePath, T value, CancellationToken ct = default); + Task ExistsAsync(string relativePath, CancellationToken ct = default); + Task DeleteAsync(string relativePath, CancellationToken ct = default); +} diff --git a/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs new file mode 100644 index 0000000..a5dc78f --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs @@ -0,0 +1,66 @@ +namespace Riverside.Elapsed.App.Services.Storage; + +public sealed class LocalJsonStore(ISerializer serializer) : ILocalJsonStore +{ + // TODO: inject something better later + private static string BasePath + => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Riverside", "Elapsed"); + + private static string FullPath(string relativePath) + => Path.Combine(BasePath, relativePath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar)); + + public Task ExistsAsync(string relativePath, CancellationToken ct = default) + => Task.FromResult(File.Exists(FullPath(relativePath))); + + public Task DeleteAsync(string relativePath, CancellationToken ct = default) + { + var path = FullPath(relativePath); + if (File.Exists(path)) + File.Delete(path); + return Task.CompletedTask; + } + + public async Task ReadAsync(string relativePath, CancellationToken ct = default) + { + var path = FullPath(relativePath); + if (!File.Exists(path)) + return default; + + await using var stream = File.OpenRead(path); + return (T?)serializer.FromStream(stream, typeof(T)); + } + + public async Task WriteAsync(string relativePath, T value, CancellationToken ct = default) + { + var path = FullPath(relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + + var tmp = path + ".tmp"; + var bak = path + ".bak"; + + await using (var stream = File.Create(tmp)) + { + serializer.ToStream(stream, value!, typeof(T)); + await stream.FlushAsync(ct).ConfigureAwait(false); + } + + // TODO: ensure compatibility with other systems + if (File.Exists(path)) + { + try + { + File.Replace(tmp, path, bak, ignoreMetadataErrors: true); + if (File.Exists(bak)) + { + File.Delete(bak); + } + return; + } + catch (PlatformNotSupportedException) { } + catch (IOException) { } + File.Delete(path); + } + + File.Move(tmp, path); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml b/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml new file mode 100644 index 0000000..edd473e --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs b/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs new file mode 100644 index 0000000..933598a --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs @@ -0,0 +1,9 @@ +namespace Riverside.Elapsed.App.UserControls; + +public sealed partial class ExploreTimelapsesControl : UserControl +{ + public ExploreTimelapsesControl() + { + this.InitializeComponent(); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml b/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml new file mode 100644 index 0000000..1ac4cef --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs b/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs new file mode 100644 index 0000000..516f6da --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs @@ -0,0 +1,9 @@ +namespace Riverside.Elapsed.App.UserControls; + +public sealed partial class LeaderboardControl : UserControl +{ + public LeaderboardControl() + { + this.InitializeComponent(); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml b/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml new file mode 100644 index 0000000..8b46748 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs b/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs new file mode 100644 index 0000000..3dccb3d --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs @@ -0,0 +1,9 @@ +namespace Riverside.Elapsed.App.UserControls; + +public sealed partial class TimelapsePlayerControl : UserControl +{ + public TimelapsePlayerControl() + { + this.InitializeComponent(); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs new file mode 100644 index 0000000..0390a3c --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs @@ -0,0 +1,22 @@ +namespace Riverside.Elapsed.App.ViewModels; + +/// +/// View-model for a single comment row rendered on the video page. +/// +public sealed class CommentViewModel +{ + /// Gets the author's display name. + public string AuthorName { get; init; } = string.Empty; + + /// Gets the author's @handle with leading at-sign. + public string AuthorHandle { get; init; } = string.Empty; + + /// Gets the comment body. + public string Body { get; init; } = string.Empty; + + /// Gets the author's profile picture URL. + public Uri? AvatarUrl { get; init; } + + /// Gets the relative posted time (e.g. "3 days ago"). + public string PostedAgo { get; init; } = string.Empty; +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs new file mode 100644 index 0000000..8aa5205 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs @@ -0,0 +1,40 @@ +using Riverside.Elapsed.App.Models.Global; + +namespace Riverside.Elapsed.App.ViewModels; + +/// +/// View-model for a single leaderboard avatar tile (used by LeaderboardControl). +/// +public sealed class LeaderboardEntryViewModel +{ + /// Gets the user's display name. + public string Name { get; init; } = string.Empty; + + /// Gets the user's @handle with leading at-sign, or empty. + public string Handle { get; init; } = string.Empty; + + /// Gets the underlying user identifier (used for profile navigation). + public string UserId { get; init; } = string.Empty; + + /// Gets the formatted weekly recording duration (e.g. "28h 20m recorded this week"). + public string WeeklyText { get; init; } = string.Empty; + + /// Gets the URL of the user's profile picture. + public Uri? ProfilePictureUrl { get; init; } + + /// Creates an entry view-model from the raw response. + public static LeaderboardEntryViewModel FromModel(LeaderboardEntry entry) + { + var seconds = entry.SecondsThisWeek; + var hours = (int)(seconds / 3600); + var minutes = (int)((seconds % 3600) / 60); + return new LeaderboardEntryViewModel + { + UserId = entry.User.UserId, + Name = entry.User.DisplayName, + Handle = string.IsNullOrWhiteSpace(entry.User.Handle) ? string.Empty : $"@{entry.User.Handle}", + WeeklyText = $"{hours}h {minutes}m recorded this week", + ProfilePictureUrl = entry.User.ProfilePictureUrl, + }; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs new file mode 100644 index 0000000..94857ed --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs @@ -0,0 +1,88 @@ +using Riverside.Elapsed.App.Models.Timelapses; +using TimelapseModel = Riverside.Elapsed.App.Models.Timelapses.Timelapse; + +namespace Riverside.Elapsed.App.ViewModels; + +/// +/// View-model for a single timelapse card used by the explore grid, the video page right-rail, +/// and the user profile grid. +/// +public sealed class TimelapseCardViewModel +{ + /// Gets the unique timelapse identifier. + public string TimelapseId { get; init; } = string.Empty; + + /// Gets the timelapse title. + public string Title { get; init; } = string.Empty; + + /// Gets the timelapse description. + public string Description { get; init; } = string.Empty; + + /// Gets the owner's identifier (used for profile navigation). + public string OwnerUserId { get; init; } = string.Empty; + + /// Gets the owner display + handle text (e.g. "Hack Club · @hackclub"). + public string OwnerText { get; init; } = string.Empty; + + /// Gets the formatted "13 days ago · Fallout for desktop" meta line. + public string MetaText { get; init; } = string.Empty; + + /// Gets the URL of the thumbnail image. + public Uri? ThumbnailUrl { get; init; } + + /// Gets the URL of the playable media. + public Uri? PlaybackUrl { get; init; } + + /// Maps a domain model into a display-ready card view-model. + public static TimelapseCardViewModel FromModel(TimelapseModel timelapse) + { + ArgumentNullException.ThrowIfNull(timelapse); + + var ageText = FormatAge(DateTimeOffset.Now - timelapse.CreatedAt); + var device = string.IsNullOrWhiteSpace(timelapse.HackatimeProject) + ? "Elapsed" + : timelapse.HackatimeProject; + + return new TimelapseCardViewModel + { + TimelapseId = timelapse.TimelapseId, + Title = timelapse.Name, + Description = timelapse.Description, + OwnerUserId = timelapse.Owner.UserId, + OwnerText = $"{timelapse.Owner.DisplayName} · @{timelapse.Owner.Handle}", + MetaText = $"{ageText} · {device}", + ThumbnailUrl = timelapse.ThumbnailUrl, + PlaybackUrl = timelapse.PlaybackUrl, + }; + } + + private static string FormatAge(TimeSpan age) + { + if (age.TotalDays >= 30) + { + var months = Math.Max(1, (int)(age.TotalDays / 30)); + return months == 1 ? "1 month ago" : $"{months} months ago"; + } + + if (age.TotalDays >= 7) + { + var weeks = Math.Max(1, (int)(age.TotalDays / 7)); + return weeks == 1 ? "1 week ago" : $"{weeks} weeks ago"; + } + + if (age.TotalDays >= 1) + { + var days = (int)age.TotalDays; + return days == 1 ? "1 day ago" : $"{days} days ago"; + } + + if (age.TotalHours >= 1) + { + var hours = (int)age.TotalHours; + return hours == 1 ? "1 hour ago" : $"{hours} hours ago"; + } + + var minutes = Math.Max(1, (int)age.TotalMinutes); + return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago"; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs new file mode 100644 index 0000000..e799301 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs @@ -0,0 +1,163 @@ +using Riverside.Elapsed.App.Models.Timelapses.Local; +using Riverside.Elapsed.App.Services.Drafts; + +namespace Riverside.Elapsed.App.ViewModels.Timelapses.Drafts; + +public sealed partial class DraftDetailsViewModel : ObservableObject +{ + private readonly ILocalDraftRepository _drafts; + private readonly INavigator _navigator; + + private CancellationTokenSource? _autosaveCts; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private string? _errorMessage; + + [ObservableProperty] + private LocalDraft? _draft; + + public DraftListItem Args { get; private set; } + + public string Name + { + get => Draft?.Name ?? ""; + set + { + if (Draft is null) + return; + if (Draft.Name == value) + return; + + Draft = Draft with + { + Name = value, + LastModifiedAt = DateTimeOffset.UtcNow, + }; + + OnPropertyChanged(nameof(Name)); + TriggerAutosave(); + SaveCommand.NotifyCanExecuteChanged(); + } + } + + public string Description + { + get => Draft?.Description ?? ""; + set + { + if (Draft is null) + return; + if (Draft.Description == value) + return; + + Draft = Draft with + { + Description = value, + LastModifiedAt = DateTimeOffset.UtcNow, + }; + + OnPropertyChanged(nameof(Description)); + TriggerAutosave(); + SaveCommand.NotifyCanExecuteChanged(); + } + } + + public IAsyncRelayCommand SaveCommand { get; } + public IAsyncRelayCommand ReloadCommand { get; } + + public DraftDetailsViewModel(ILocalDraftRepository drafts, INavigator navigator) + { + _drafts = drafts; + _navigator = navigator; + + SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave); + ReloadCommand = new AsyncRelayCommand(ReloadAsync); + } + + public async Task OnNavigatedToAsync(DraftListItem args) + { + Args = args; + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + if (Args is null) + { + ErrorMessage = "Missing navigation arguments."; + return; + } + + try + { + ErrorMessage = null; + IsLoading = true; + + var loaded = await _drafts.GetDraftAsync(Args.LocalDraftId); + if (loaded is null) + { + ErrorMessage = "Draft not found (it may have been deleted)."; + Draft = null; + return; + } + + Draft = loaded; + + OnPropertyChanged(nameof(Name)); + OnPropertyChanged(nameof(Description)); + SaveCommand.NotifyCanExecuteChanged(); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsLoading = false; + } + } + + private bool CanSave() + => Draft is not null; + + private async Task SaveAsync() + { + if (Draft is null) + return; + + try + { + ErrorMessage = null; + await _drafts.SaveDraftAsync(Draft); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + } + + private void TriggerAutosave() + { + _autosaveCts?.Cancel(); + _autosaveCts?.Dispose(); + + _autosaveCts = new(); + var token = _autosaveCts.Token; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromMilliseconds(500), token); + if (token.IsCancellationRequested) + return; + + await SaveAsync(); + } + catch (OperationCanceledException) { } + }, token); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs new file mode 100644 index 0000000..77ef317 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs @@ -0,0 +1,3 @@ +namespace Riverside.Elapsed.App.ViewModels.Timelapses.Drafts; + +public sealed record DraftListItem(Guid LocalDraftId); diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs new file mode 100644 index 0000000..affb155 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs @@ -0,0 +1,19 @@ +namespace Riverside.Elapsed.App.ViewModels.Timelapses.Drafts; + +public sealed partial class DraftListItemViewModel +{ + public Guid LocalDraftId { get; } + public string Name { get; } + public DateTimeOffset LastModifiedAt { get; } + public bool HasRemoteDraft { get; } + public string? RemoteDraftTimelapseId { get; } + + public DraftListItemViewModel(Guid localDraftId, string name, DateTimeOffset lastModifiedAt, bool hasRemoteDraft, string? remoteDraftTimelapseId) + { + LocalDraftId = localDraftId; + Name = name; + LastModifiedAt = lastModifiedAt; + HasRemoteDraft = hasRemoteDraft; + RemoteDraftTimelapseId = remoteDraftTimelapseId; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs new file mode 100644 index 0000000..b0c7bb7 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs @@ -0,0 +1,134 @@ +using System.Collections.ObjectModel; +using Riverside.Elapsed.App.Models.Timelapses.Local; +using Riverside.Elapsed.App.Services.Drafts; + +namespace Riverside.Elapsed.App.ViewModels.Timelapses.Drafts; + +public sealed partial class DraftsViewModel : ObservableObject +{ + private readonly ILocalDraftRepository _drafts; + private readonly INavigator _navigator; + private readonly IDispatcher _dispatcher; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private string? _errorMessage; + + public ObservableCollection Items { get; } + + public IAsyncRelayCommand RefreshCommand { get; } + public IAsyncRelayCommand CreateNewDraftCommand { get; } + public IAsyncRelayCommand DeleteDraftCommand { get; } + public IAsyncRelayCommand OpenDraftCommand { get; } + + public DraftsViewModel(ILocalDraftRepository drafts, INavigator navigator, IDispatcher dispatcher) + { + _drafts = drafts; + _navigator = navigator; + _dispatcher = dispatcher; + + Items = []; + + RefreshCommand = new AsyncRelayCommand(RefreshAsync); + CreateNewDraftCommand = new AsyncRelayCommand(CreateNewDraftAsync); + DeleteDraftCommand = new AsyncRelayCommand(DeleteDraftAsync); + OpenDraftCommand = new AsyncRelayCommand(OpenDraftAsync); + } + + public async Task RefreshAsync() + { + try + { + ErrorMessage = null; + IsLoading = true; + + var index = await _drafts.GetIndexAsync(); + + await _dispatcher.ExecuteAsync(() => + { + Items.Clear(); + foreach (var d in index.Drafts) + { + Items.Add(new( + d.LocalDraftId, + string.IsNullOrWhiteSpace(d.Name) ? "Untitled draft" : d.Name, // TODO: Localise + d.LastModifiedAt, + d.HasRemoteDraft, + d.RemoteDraftTimelapseId)); + } + }); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + finally + { + IsLoading = false; + } + } + + public async Task CreateNewDraftAsync() + { + try + { + ErrorMessage = null; + + var deviceId = Guid.Empty; // TODO: Replace with local device registration store + var now = DateTimeOffset.UtcNow; + var draft = new LocalDraft + { + LocalDraftId = Guid.NewGuid(), + CreatedAt = now, + LastModifiedAt = now, + Name = string.Empty, + Description = string.Empty, + Snapshots = [], + EditList = [], + Sessions = [], + Thumbnail = new(), + Remote = null, + State = new(), + }; + + await _drafts.SaveDraftAsync(draft); + await RefreshAsync(); + await OpenDraftAsync(draft.LocalDraftId); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + } + + private async Task DeleteDraftAsync(Guid localDraftId) + { + try + { + ErrorMessage = null; + + await _drafts.DeleteDraftAsync(localDraftId); + await _dispatcher.ExecuteAsync(() => + { + var item = Items.FirstOrDefault(x => x.LocalDraftId == localDraftId); + if (item is not null) + { + Items.Remove(item); + } + }); + } + catch (Exception ex) + { + ErrorMessage = ex.Message; + } + } + + private Task OpenDraftAsync(Guid localDraftId) + { + return _navigator.NavigateViewModelAsync( + this, + data: new DraftListItem(localDraftId)); + } +} From 19c6ac885120fac5dbd38c0c6d26c0ee90fe70bf Mon Sep 17 00:00:00 2001 From: ascpixi <44982772+ascpixi@users.noreply.github.com> Date: Sun, 31 May 2026 21:35:40 -0400 Subject: [PATCH 02/13] Scope app to MVP recording flow with desktop capture UI --- CLAUDE.md | 111 +++++++ src/platforms/Riverside.Elapsed.App/App.xaml | 71 ++++- .../Riverside.Elapsed.App/App.xaml.cs | 16 +- .../BoolToPausedRecordingLabelConverter.cs | 16 + .../Converters/BoolToToggleColorConverter.cs | 23 ++ .../Json/TimeSpanSecondsJsonConverter.cs | 3 + .../Converters/Json/UriJsonConverter.cs | 3 + .../Converters/NullToCollapsedConverter.cs | 17 + .../Models/Admin/AdminExport.cs | 3 + .../Models/Admin/AdminListPage.cs | 3 + .../Models/Admin/AdminListResponse.cs | 3 + .../Models/Admin/AdminSearchResult.cs | 3 + .../Models/Admin/AdminSearchResults.cs | 3 + .../Models/Admin/AdminStats.cs | 3 + .../Models/Admin/AdminUpdateResult.cs | 3 + .../Models/Admin/EntityType.cs | 3 + .../Models/Admin/ProgramKeyList.cs | 3 + .../Models/Admin/ProgramKeyMetadata.cs | 3 + .../Models/Admin/ProgramKeySecret.cs | 3 + .../Riverside.Elapsed.App/Models/AppConfig.cs | 3 + .../Models/Auth/OAuthToken.cs | 3 + .../Models/Developer/DeveloperApp.cs | 3 + .../Models/Developer/OAuthAppList.cs | 3 + .../Models/Developer/OAuthAppSecret.cs | 3 + .../Models/Developer/OAuthGrant.cs | 3 + .../Models/Developer/OAuthGrantList.cs | 3 + .../Models/Developer/OAuthGrantListPage.cs | 3 + .../Models/Developer/TrustLevel.cs | 3 + .../Models/Global/ActiveUsers.cs | 3 + .../Models/Global/LeaderboardEntry.cs | 3 + .../Models/Hackatime/HackatimeProject.cs | 3 + .../Hackatime/HackatimeProjectTimelapses.cs | 3 + .../Models/Primitives/IUploadable.cs | 3 + .../Models/Recording/CaptureDevice.cs | 3 + .../Models/Recording/CaptureSourceKind.cs | 7 + .../Models/Recording/RecordingPhase.cs | 8 + .../Models/Timelapses/Comment.cs | 3 + .../Models/Timelapses/CursorPage{T}.cs | 3 + .../Models/Timelapses/DraftEdit.cs | 3 + .../Models/Timelapses/DraftTimelapse.cs | 3 + .../Models/Timelapses/EditKind.cs | 3 + .../Timelapses/Local/DraftPipelineState.cs | 3 + .../Models/Timelapses/Local/LocalDraft.cs | 3 + .../Timelapses/Local/LocalDraftIndex.cs | 3 + .../Timelapses/Local/LocalDraftIndexItem.cs | 3 + .../Models/Timelapses/Local/LocalSession.cs | 3 + .../Models/Timelapses/Local/LocalThumbnail.cs | 3 + .../Timelapses/Local/RemoteDraftSync.cs | 3 + .../Models/Timelapses/Local/TusUploadState.cs | 3 + .../Models/Timelapses/Timelapse.cs | 3 + .../Models/Timelapses/Visibility.cs | 3 + .../Models/User/Device.cs | 3 + .../Models/User/Local/DeviceKey.cs | 3 + .../Models/User/Local/KeyRelayRequest.cs | 3 + .../Models/User/Local/KeyRelayResult.cs | 3 + .../Models/User/Myself.cs | 3 + .../Models/User/PermissionLevel.cs | 3 + .../Riverside.Elapsed.App/Models/User/User.cs | 3 + .../Models/User/UserDetails.cs | 3 + .../Riverside.Elapsed.App.csproj | 22 +- .../Services/Auth/AuthTokenStore.cs | 3 + .../Services/Auth/IAuthTokenStore.cs | 3 + .../Services/Build/BuildInfo.cs | 3 + .../Services/Build/BuildInfoProvider.cs | 3 + .../Services/Build/IBuildInfoProvider.cs | 3 + .../DraftsServiceCollectionExtensions.cs | 3 + .../Services/Drafts/ILocalDraftRepository.cs | 3 + .../Services/Drafts/LocalDraftRepository.cs | 3 + .../Services/Endpoints/DebugHandler.cs | 3 + .../Services/Storage/ILocalJsonStore.cs | 3 + .../Services/Storage/LocalJsonStore.cs | 3 + .../ExploreTimelapsesControl.xaml.cs | 3 + .../UserControls/LeaderboardControl.xaml.cs | 3 + .../TimelapsePlayerControl.xaml.cs | 3 + .../ViewModels/CommentViewModel.cs | 3 + .../ViewModels/LeaderboardEntryViewModel.cs | 3 + .../ViewModels/MainViewModel.cs | 158 ++++++++++ .../ViewModels/ShellViewModel.cs | 5 + .../ViewModels/TimelapseCardViewModel.cs | 3 + .../Drafts/DraftDetailsViewModel.cs | 3 + .../Timelapses/Drafts/DraftListItem.cs | 3 + .../Drafts/DraftListItemViewModel.cs | 3 + .../Timelapses/Drafts/DraftsViewModel.cs | 3 + .../Views/LoginPage.xaml.cs | 3 + .../Views/MainPage.xaml.cs | 3 + .../Views/RecordingPage.xaml | 292 ++++++++++++++++++ .../Views/RecordingPage.xaml.cs | 64 ++++ .../Riverside.Elapsed.App/Views/Shell.xaml | 2 +- .../Riverside.Elapsed.App/Views/Shell.xaml.cs | 2 + 89 files changed, 1021 insertions(+), 15 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/BoolToPausedRecordingLabelConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/BoolToToggleColorConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Converters/NullToCollapsedConverter.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureDevice.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml create mode 100644 src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs 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/src/platforms/Riverside.Elapsed.App/App.xaml b/src/platforms/Riverside.Elapsed.App/App.xaml index 29d115e..93f4ad3 100644 --- a/src/platforms/Riverside.Elapsed.App/App.xaml +++ b/src/platforms/Riverside.Elapsed.App/App.xaml @@ -12,7 +12,76 @@ - + + + + + + + + + diff --git a/src/platforms/Riverside.Elapsed.App/App.xaml.cs b/src/platforms/Riverside.Elapsed.App/App.xaml.cs index 76be3c7..765ca5d 100644 --- a/src/platforms/Riverside.Elapsed.App/App.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/App.xaml.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Riverside.Elapsed.App.Services.Recording; using Riverside.Elapsed.App.ViewModels; using Uno.Resizetizer; @@ -12,7 +13,6 @@ public App() } protected Window? MainWindow { get; private set; } - protected IHost? Host { get; private set; } [SuppressMessage("Trimming", "IL2026", Justification = "Uno app builder usage is trim-safe for configured features.")] protected override async void OnLaunched(LaunchActivatedEventArgs args) @@ -46,12 +46,22 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) #endif MainWindow.SetWindowIcon(); - Host = await builder.NavigateAsync(initialNavigate: async (services, navigator) => +#if HAS_MEDIA_RECORDING + IRecordingFacade recording = OperatingSystem.IsWindows() + ? new WindowsRecordingFacade() + : new NoOpRecordingFacade(); +#else + IRecordingFacade recording = new NoOpRecordingFacade(); +#endif + + MainWindow.Content = new RecordingPage { //var authService = services.GetRequiredService(); //await authService.TryRestoreSessionAsync(); await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); - }); + DataContext = new RecordingViewModel(recording) + }; + MainWindow.Activate(); } private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) 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..051181a --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/BoolToPausedRecordingLabelConverter.cs @@ -0,0 +1,16 @@ +using Microsoft.UI.Xaml.Data; + +namespace Riverside.Elapsed.App.Converters; + +public sealed class BoolToPausedRecordingLabelConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + return value is true ? "Paused" : "Recording"; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotSupportedException(); + } +} 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..74c248d --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/BoolToToggleColorConverter.cs @@ -0,0 +1,23 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; + +namespace Riverside.Elapsed.App.Converters; + +public sealed class BoolToToggleColorConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + if (value is true) + { + return new SolidColorBrush(Microsoft.UI.Colors.DodgerBlue); + } + + return new SolidColorBrush(Microsoft.UI.Colors.Coral); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotSupportedException(); + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs index 28688a4..957422e 100644 --- a/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs +++ b/src/platforms/Riverside.Elapsed.App/Converters/Json/TimeSpanSecondsJsonConverter.cs @@ -1,3 +1,4 @@ +#if false using System; using System.Collections.Generic; using System.Globalization; @@ -29,3 +30,5 @@ public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializer writer.WriteNumberValue(value.TotalSeconds); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs b/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs index 16c156e..9ce87bf 100644 --- a/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs +++ b/src/platforms/Riverside.Elapsed.App/Converters/Json/UriJsonConverter.cs @@ -1,3 +1,4 @@ +#if false using System.Text.Json; using System.Text.Json.Serialization; @@ -19,3 +20,5 @@ public override void Write(Utf8JsonWriter writer, Uri value, JsonSerializerOptio writer.WriteStringValue(value.ToString()); } } + +#endif 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..8efb34e --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Converters/NullToCollapsedConverter.cs @@ -0,0 +1,17 @@ +using Microsoft.UI.Xaml; +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) + { + return value is null or "" ? 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/CaptureSourceKind.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs new file mode 100644 index 0000000..fc1e6d7 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs @@ -0,0 +1,7 @@ +namespace Riverside.Elapsed.App.Models.Recording; + +public enum CaptureSourceKind +{ + Screen, + Window, +} 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..1615e5a --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs @@ -0,0 +1,8 @@ +namespace Riverside.Elapsed.App.Models.Recording; + +public enum RecordingPhase +{ + Setup, + Active, + Paused, +} 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..25931a3 100644 --- a/src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj +++ b/src/platforms/Riverside.Elapsed.App/Riverside.Elapsed.App.csproj @@ -10,18 +10,8 @@ https://aka.platform.uno/singleproject-features --> - Lottie; - Hosting; Toolkit; - Logging; - LoggingSerilog; Mvvm; - Configuration; - HttpKiota; - Serialization; - Localization; - Authentication; - Navigation; ThemeService; SkiaRenderer; @@ -49,6 +39,18 @@ + + + + + + + + + + + + $(DefineConstants);HAS_MEDIA_RECORDING diff --git a/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs index 125ff38..65803e1 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Auth/AuthTokenStore.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Auth; using Riverside.Elapsed.App.Services.Storage; @@ -29,3 +30,5 @@ public async Task ClearAsync(CancellationToken cancellationToken = default) await store.DeleteAsync(TokenPath, cancellationToken); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs index f872f8d..13d6ed3 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Auth/IAuthTokenStore.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Auth; namespace Riverside.Elapsed.App.Services.Auth; @@ -11,3 +12,5 @@ public interface IAuthTokenStore Task SetTokenAsync(OAuthToken token, CancellationToken cancellationToken = default); Task ClearAsync(CancellationToken cancellationToken = default); } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs index e4c55c7..1a00141 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfo.cs @@ -1,3 +1,4 @@ +#if false using System.Globalization; namespace Riverside.Elapsed.App.Services.Build; @@ -31,3 +32,5 @@ public BuildInfo() WebFooterText = compact; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs index 68249ff..62ffcd5 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Build/BuildInfoProvider.cs @@ -1,3 +1,4 @@ +#if false using System.Globalization; namespace Riverside.Elapsed.App.Services.Build; @@ -28,3 +29,5 @@ public BuildInfo GetBuildInfo() }; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs index 0173b43..e0438d1 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Build/IBuildInfoProvider.cs @@ -1,6 +1,9 @@ +#if false namespace Riverside.Elapsed.App.Services.Build; public interface IBuildInfoProvider { BuildInfo GetBuildInfo(); } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs b/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs index 0400925..334e7aa 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Drafts/DraftsServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Services.Drafts; namespace Riverside.Elapsed.App.Extensions; @@ -10,3 +11,5 @@ public static IServiceCollection AddDrafts(this IServiceCollection services) return services; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs b/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs index 6f2580e..9923915 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Drafts/ILocalDraftRepository.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Timelapses.Local; namespace Riverside.Elapsed.App.Services.Drafts; @@ -10,3 +11,5 @@ public interface ILocalDraftRepository Task SaveDraftAsync(LocalDraft draft, CancellationToken ct = default); Task DeleteDraftAsync(Guid localDraftId, CancellationToken ct = default); } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs b/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs index 55c1a18..5b9c6fe 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Drafts/LocalDraftRepository.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Timelapses.Local; using Riverside.Elapsed.App.Services.Storage; @@ -55,3 +56,5 @@ public async Task DeleteDraftAsync(Guid localDraftId, CancellationToken ct = def await store.WriteAsync(IndexPath, index with { Drafts = updated }, ct).ConfigureAwait(false); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs b/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs index f5680ca..ef5a460 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Endpoints/DebugHandler.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.Services.Endpoints; internal class DebugHttpHandler : DelegatingHandler @@ -42,3 +43,5 @@ protected async override Task SendAsync( return response; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs index e3fb216..998b007 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Storage/ILocalJsonStore.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.Services.Storage; public interface ILocalJsonStore @@ -7,3 +8,5 @@ public interface ILocalJsonStore Task ExistsAsync(string relativePath, CancellationToken ct = default); Task DeleteAsync(string relativePath, CancellationToken ct = default); } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs b/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs index a5dc78f..952aaeb 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Storage/LocalJsonStore.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.Services.Storage; public sealed class LocalJsonStore(ISerializer serializer) : ILocalJsonStore @@ -64,3 +65,5 @@ public async Task WriteAsync(string relativePath, T value, CancellationToken File.Move(tmp, path); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs b/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs index 933598a..4855e02 100644 --- a/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/UserControls/ExploreTimelapsesControl.xaml.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.UserControls; public sealed partial class ExploreTimelapsesControl : UserControl @@ -7,3 +8,5 @@ public ExploreTimelapsesControl() this.InitializeComponent(); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs b/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs index 516f6da..de824cf 100644 --- a/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/UserControls/LeaderboardControl.xaml.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.UserControls; public sealed partial class LeaderboardControl : UserControl @@ -7,3 +8,5 @@ public LeaderboardControl() this.InitializeComponent(); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs b/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs index 3dccb3d..4a9a4a8 100644 --- a/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/UserControls/TimelapsePlayerControl.xaml.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.UserControls; public sealed partial class TimelapsePlayerControl : UserControl @@ -7,3 +8,5 @@ public TimelapsePlayerControl() this.InitializeComponent(); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs index 0390a3c..0bc997b 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/CommentViewModel.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.ViewModels; /// @@ -20,3 +21,5 @@ public sealed class CommentViewModel /// Gets the relative posted time (e.g. "3 days ago"). public string PostedAgo { get; init; } = string.Empty; } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs index 8aa5205..02751cd 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/LeaderboardEntryViewModel.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Global; namespace Riverside.Elapsed.App.ViewModels; @@ -38,3 +39,5 @@ public static LeaderboardEntryViewModel FromModel(LeaderboardEntry entry) }; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs index e74e101..300d6d1 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/MainViewModel.cs @@ -1,5 +1,163 @@ +#if false +using System.Collections.ObjectModel; +using Riverside.Elapsed.App.Services.Api; +using Riverside.Elapsed.App.Services.Auth; +using Riverside.Elapsed.App.Services.Build; + namespace Riverside.Elapsed.App.ViewModels; +/// +/// Backs the home page — leaderboard, explore timelapses, and the welcome banner shown to +/// signed-out viewers. +/// public partial class MainViewModel : ObservableObject { + private readonly INavigator _navigator; + private readonly ILapseAuthService _authService; + private readonly IApiGlobalService _globalService; + private readonly BuildInfo _buildInfo; + + [ObservableProperty] + private bool _isLoading; + + [ObservableProperty] + private string? _errorMessage; + + [ObservableProperty] + private string _searchText = string.Empty; + + [ObservableProperty] + private bool _isAuthenticated; + + public MainViewModel( + INavigator navigator, + ILapseAuthService authService, + IApiGlobalService globalService, + IBuildInfoProvider buildInfoProvider) + { + _navigator = navigator; + _authService = authService; + _globalService = globalService; + _buildInfo = buildInfoProvider.GetBuildInfo(); + + IsAuthenticated = _authService.IsAuthenticated; + _authService.LoggedIn += (_, _) => IsAuthenticated = true; + _authService.LoggedOut += (_, _) => IsAuthenticated = false; + + RefreshCommand = new AsyncRelayCommand(LoadAsync); + LogoutCommand = new AsyncRelayCommand(LogoutAsync); + OpenRecordingCommand = new AsyncRelayCommand(() => _navigator.NavigateViewModelAsync(this)); + OpenTimelapseCommand = new AsyncRelayCommand(OpenTimelapseAsync); + OpenProfileCommand = new AsyncRelayCommand(OpenProfileAsync); + SignInCommand = new AsyncRelayCommand(SignInAsync); + } + + public ObservableCollection LeaderboardEntries { get; } = []; + + public ObservableCollection ExploreTimelapses { get; } = []; + + public bool IsWebPlatform => OperatingSystem.IsBrowser(); + + public string FooterText => _buildInfo.FullFooterText; + + public string WebFooterText => _buildInfo.WebFooterText; + + public string GreetingTitle => "Welcome to Elapsed, Hack Club's timelapse tracking tool!"; + + public string GreetingSubtitle => "Sign in to start tracking your own time with Elapsed"; + + public IAsyncRelayCommand RefreshCommand { get; } + + public IAsyncRelayCommand LogoutCommand { get; } + + public IAsyncRelayCommand OpenRecordingCommand { get; } + + public IAsyncRelayCommand OpenTimelapseCommand { get; } + + public IAsyncRelayCommand OpenProfileCommand { get; } + + public IAsyncRelayCommand SignInCommand { get; } + + public async Task InitializeAsync() + { + if (LeaderboardEntries.Count == 0 && ExploreTimelapses.Count == 0) + { + await LoadAsync(); + } + } + + private async Task LoadAsync() + { + IsLoading = true; + ErrorMessage = null; + + try + { + var leaderboardTask = _globalService.GetWeeklyLeaderboardAsync(); + var recentTask = _globalService.GetRecentTimelapsesAsync(); + + var leaderboardResult = await leaderboardTask; + var recentResult = await recentTask; + + LeaderboardEntries.Clear(); + if (leaderboardResult.IsSuccess && leaderboardResult.Value is not null) + { + foreach (var entry in leaderboardResult.Value) + { + LeaderboardEntries.Add(LeaderboardEntryViewModel.FromModel(entry)); + } + } + + ExploreTimelapses.Clear(); + if (recentResult.IsSuccess && recentResult.Value is not null) + { + foreach (var timelapse in recentResult.Value) + { + ExploreTimelapses.Add(TimelapseCardViewModel.FromModel(timelapse)); + } + } + + if (!leaderboardResult.IsSuccess || !recentResult.IsSuccess) + { + ErrorMessage = leaderboardResult.ErrorMessage ?? recentResult.ErrorMessage ?? "Some content could not be loaded."; + } + } + finally + { + IsLoading = false; + } + } + + private async Task LogoutAsync() + { + await _authService.LogoutAsync(); + await _navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.ClearBackStack); + } + + private async Task OpenTimelapseAsync(TimelapseCardViewModel? card) + { + if (card is null || string.IsNullOrWhiteSpace(card.TimelapseId)) + { + return; + } + + await _navigator.NavigateViewModelAsync(this, data: card); + } + + private async Task OpenProfileAsync(LeaderboardEntryViewModel? entry) + { + if (entry is null || string.IsNullOrWhiteSpace(entry.UserId)) + { + return; + } + + await _navigator.NavigateViewModelAsync(this, data: entry.UserId); + } + + private async Task SignInAsync() + { + await _navigator.NavigateViewModelAsync(this); + } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs index c2d75d6..bc0b069 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/ShellViewModel.cs @@ -1,5 +1,10 @@ +#if false namespace Riverside.Elapsed.App.ViewModels; public sealed partial class ShellViewModel : ObservableObject { + public ShellViewModel(INavigator navigator) + { + } } +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs index 94857ed..273293b 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/TimelapseCardViewModel.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Timelapses; using TimelapseModel = Riverside.Elapsed.App.Models.Timelapses.Timelapse; @@ -86,3 +87,5 @@ private static string FormatAge(TimeSpan age) return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago"; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs index e799301..eb2d309 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftDetailsViewModel.cs @@ -1,3 +1,4 @@ +#if false using Riverside.Elapsed.App.Models.Timelapses.Local; using Riverside.Elapsed.App.Services.Drafts; @@ -161,3 +162,5 @@ private void TriggerAutosave() }, token); } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs index 77ef317..74268b3 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItem.cs @@ -1,3 +1,6 @@ +#if false namespace Riverside.Elapsed.App.ViewModels.Timelapses.Drafts; public sealed record DraftListItem(Guid LocalDraftId); + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs index affb155..3f336cb 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftListItemViewModel.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.ViewModels.Timelapses.Drafts; public sealed partial class DraftListItemViewModel @@ -17,3 +18,5 @@ public DraftListItemViewModel(Guid localDraftId, string name, DateTimeOffset las RemoteDraftTimelapseId = remoteDraftTimelapseId; } } + +#endif diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs index b0c7bb7..c103692 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/Timelapses/Drafts/DraftsViewModel.cs @@ -1,3 +1,4 @@ +#if false using System.Collections.ObjectModel; using Riverside.Elapsed.App.Models.Timelapses.Local; using Riverside.Elapsed.App.Services.Drafts; @@ -132,3 +133,5 @@ private Task OpenDraftAsync(Guid localDraftId) data: new DraftListItem(localDraftId)); } } + +#endif 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.cs b/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml.cs index 2185b0e..ce013a5 100644 --- a/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/Views/MainPage.xaml.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.Views; public sealed partial class MainPage : Page @@ -16,3 +17,5 @@ private async void OnLoaded(object sender, RoutedEventArgs e) } } } + +#endif 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..246aa82 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..04e3ae2 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs @@ -0,0 +1,64 @@ +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 CompactWidth = 340; + private const int CompactHeight = 460; + private const int ExpandedWidth = 800; + private const int ExpandedHeight = 460; + + public RecordingPage() + { + this.InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + SetWindowSize(CompactWidth, CompactHeight); + + if (DataContext is RecordingViewModel vm) + { + vm.RecordingStarted += OnRecordingStarted; + vm.RecordingStopped += OnRecordingStopped; + + ScreenRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Screen; + WindowRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Window; + } + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (DataContext is RecordingViewModel vm) + { + vm.RecordingStarted -= OnRecordingStarted; + vm.RecordingStopped -= OnRecordingStopped; + } + } + + private void OnRecordingStarted(object? sender, EventArgs e) + { + SetWindowSize(ExpandedWidth, ExpandedHeight); + } + + private void OnRecordingStopped(object? sender, EventArgs e) + { + SetWindowSize(CompactWidth, CompactHeight); + } + + private static void SetWindowSize(int width, int height) + { + var window = App.CurrentMainWindow; + 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 index 39327c7..430c51d 100644 --- a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml +++ b/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml @@ -26,7 +26,7 @@ Padding="4,0,8,0" BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}" BorderThickness="0,0,0,1" - Visibility="{Binding IsDesktopChromeVisible, Converter={StaticResource BoolToVisibilityConverter}}"> + Visibility="Collapsed"> diff --git a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs b/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs index 60e2162..73007ac 100644 --- a/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/Views/Shell.xaml.cs @@ -1,3 +1,4 @@ +#if false namespace Riverside.Elapsed.App.Views; public sealed partial class Shell : UserControl, IContentControlProvider @@ -9,3 +10,4 @@ public Shell() public ContentControl ContentControl => Splash; } +#endif From f8c81fe5bbe78d1f63c6deb6d4b1698956caaea1 Mon Sep 17 00:00:00 2001 From: ascpixi <44982772+ascpixi@users.noreply.github.com> Date: Sun, 31 May 2026 22:42:15 -0400 Subject: [PATCH 03/13] Add source enumeration with thumbnails and live recording preview --- src/platforms/Riverside.Elapsed.App/App.xaml | 1 - .../Riverside.Elapsed.App/App.xaml.cs | 19 +- .../Models/Recording/CaptureSource.cs | 11 + .../Models/Recording/CaptureSourceKind.cs | 1 + .../Recording/ICaptureSourceProvider.cs | 9 + .../Recording/NoOpCaptureSourceProvider.cs | 12 + .../Recording/WindowsCaptureSourceProvider.cs | 475 ++++++++++++++++++ .../ViewModels/RecordingViewModel.cs | 224 +++++++++ .../Views/RecordingPage.xaml | 194 ++++--- .../Views/RecordingPage.xaml.cs | 30 +- 10 files changed, 862 insertions(+), 114 deletions(-) create mode 100644 src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSource.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs create mode 100644 src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs diff --git a/src/platforms/Riverside.Elapsed.App/App.xaml b/src/platforms/Riverside.Elapsed.App/App.xaml index 93f4ad3..0c1f081 100644 --- a/src/platforms/Riverside.Elapsed.App/App.xaml +++ b/src/platforms/Riverside.Elapsed.App/App.xaml @@ -15,7 +15,6 @@ - diff --git a/src/platforms/Riverside.Elapsed.App/App.xaml.cs b/src/platforms/Riverside.Elapsed.App/App.xaml.cs index 765ca5d..16bc0b5 100644 --- a/src/platforms/Riverside.Elapsed.App/App.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/App.xaml.cs @@ -47,11 +47,22 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) MainWindow.SetWindowIcon(); #if HAS_MEDIA_RECORDING - IRecordingFacade recording = OperatingSystem.IsWindows() - ? new WindowsRecordingFacade() - : new NoOpRecordingFacade(); + IRecordingFacade recording; + ICaptureSourceProvider sourceProvider; + + if (OperatingSystem.IsWindows()) + { + recording = new WindowsRecordingFacade(); + sourceProvider = new WindowsCaptureSourceProvider(); + } + else + { + recording = new NoOpRecordingFacade(); + sourceProvider = new NoOpCaptureSourceProvider(); + } #else IRecordingFacade recording = new NoOpRecordingFacade(); + ICaptureSourceProvider sourceProvider = new NoOpCaptureSourceProvider(); #endif MainWindow.Content = new RecordingPage @@ -59,7 +70,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) //var authService = services.GetRequiredService(); //await authService.TryRestoreSessionAsync(); await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); - DataContext = new RecordingViewModel(recording) + DataContext = new RecordingViewModel(recording, sourceProvider) }; MainWindow.Activate(); } 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..d09b22d --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSource.cs @@ -0,0 +1,11 @@ +namespace Riverside.Elapsed.App.Models.Recording; + +public sealed class CaptureSource +{ + public required string Id { get; init; } + public required string Name { get; init; } + public string? Description { get; init; } + public string? Resolution { get; init; } + public required CaptureSourceKind Kind { get; init; } + public Microsoft.UI.Xaml.Media.ImageSource? Thumbnail { get; set; } +} diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs index fc1e6d7..4fd1117 100644 --- a/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs +++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/CaptureSourceKind.cs @@ -4,4 +4,5 @@ public enum CaptureSourceKind { Screen, Window, + Camera, } 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..0c10c9c --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs @@ -0,0 +1,9 @@ +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); +} 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..899f955 --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs @@ -0,0 +1,12 @@ +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); +} 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..f2e58cd --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs @@ -0,0 +1,475 @@ +#if HAS_MEDIA_RECORDING +using System.Diagnostics; +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 WindowsCaptureSourceProvider : ICaptureSourceProvider +{ + private const int ThumbMaxWidth = 220; + private const int ThumbMaxHeight = 140; + + public Task> GetSourcesAsync(CaptureSourceKind kind) + { + var items = kind switch + { + CaptureSourceKind.Screen => EnumerateScreens(), + CaptureSourceKind.Window => EnumerateWindows(), + CaptureSourceKind.Camera => EnumerateCameras(), + _ => [] + }; + + foreach (var (source, pixels, tw, th) in items) + { + if (pixels is not null) + source.Thumbnail = CreateThumbnail(pixels, tw, th); + } + + return Task.FromResult>(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)); + } + + private List<(CaptureSource source, byte[]? pixels, int tw, int th)> EnumerateScreens() + { + var results = new List<(CaptureSource, byte[]?, int, int)>(); + 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() + { + var results = new List<(CaptureSource, byte[]?, int, int)>(); + 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; + + string 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, + }, pixels, tw, th)); + + return true; + }; + + Native.EnumWindows(callback, nint.Zero); + GC.KeepAlive(callback); + return results; + } + + private static List<(CaptureSource source, byte[]? pixels, int tw, int th)> EnumerateCameras() + { + var results = new List<(CaptureSource, byte[]?, int, int)>(); + var name = new StringBuilder(256); + var ver = new StringBuilder(256); + + for (uint i = 0; i < 10; i++) + { + name.Clear(); + ver.Clear(); + if (Native.capGetDriverDescriptionW(i, name, 256, ver, 256)) + { + string n = name.ToString(); + results.Add((new CaptureSource + { + Id = $"camera-{i}", + Name = string.IsNullOrWhiteSpace(n) ? $"Camera {i}" : n, + Description = ver.ToString(), + Kind = CaptureSourceKind.Camera, + }, null, 0, 0)); + } + } + + return results; + } + + 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 + { + return null; + } + } + + private static byte[] EncodeBmp(byte[] bgraPixels, int width, int height) + { + 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), 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; + } + + 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); + + [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; + } + } +} +#endif 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..1449d7b --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs @@ -0,0 +1,224 @@ +using System.Collections.ObjectModel; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Media; +using Riverside.Elapsed.App.Models.Recording; +using Riverside.Elapsed.App.Services.Recording; + +namespace Riverside.Elapsed.App.ViewModels; + +public sealed partial class RecordingViewModel : ObservableObject, IDisposable +{ + private readonly IRecordingFacade _recording; + private readonly ICaptureSourceProvider _sourceProvider; + private readonly DispatcherQueueTimer? _timer; + private readonly DispatcherQueueTimer? _previewTimer; + private bool _previewUpdating; + + [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; + + public RecordingViewModel(IRecordingFacade recording, ICaptureSourceProvider sourceProvider) + { + _recording = recording; + _sourceProvider = sourceProvider; + _recording.StateChanged += OnRecordingStateChanged; + + StartRecordingCommand = new AsyncRelayCommand(StartRecordingAsync, () => Phase == RecordingPhase.Setup && SelectedSource is not null); + PauseResumeCommand = new AsyncRelayCommand(TogglePauseResumeAsync, () => Phase is RecordingPhase.Active or RecordingPhase.Paused); + StopCommand = new AsyncRelayCommand(StopAsync, () => Phase is RecordingPhase.Active or RecordingPhase.Paused); + + 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(); + } + + _ = RefreshSourcesAsync(); + } + + public ObservableCollection CurrentSources { get; } = []; + + public bool IsInSetup => Phase == RecordingPhase.Setup; + + public bool IsActive => Phase != RecordingPhase.Setup; + + public bool IsPaused => Phase == RecordingPhase.Paused; + + 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 event EventHandler? RecordingStarted; + + public event EventHandler? RecordingStopped; + + 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(IsPaused)); + OnPropertyChanged(nameof(PauseResumeLabel)); + OnPropertyChanged(nameof(PauseResumeGlyph)); + StartRecordingCommand.NotifyCanExecuteChanged(); + PauseResumeCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); + UpdatePreviewTimer(); + } + + private async Task RefreshSourcesAsync() + { + CurrentSources.Clear(); + var sources = await _sourceProvider.GetSourcesAsync(SelectedSourceKind); + Console.Error.WriteLine($"[Elapsed] {SelectedSourceKind}: {sources.Count} source(s)"); + foreach (var source in sources) + { + Console.Error.WriteLine($"[Elapsed] - {source.Name} | thumb={source.Thumbnail is not null}"); + CurrentSources.Add(source); + } + SelectedSource = null; + } + + private void UpdatePreviewTimer() + { + if (_previewTimer is null) return; + + if (SelectedSource is not null && Phase != RecordingPhase.Setup) + _previewTimer.Start(); + else + _previewTimer.Stop(); + } + + private async Task RefreshPreviewAsync() + { + if (_previewUpdating || SelectedSource is null) + return; + + _previewUpdating = true; + try + { + var image = await _sourceProvider.CapturePreviewAsync(SelectedSource, 640, 480); + if (image is not null) + PreviewImage = image; + } + catch { /* capture may fail transiently */ } + finally + { + _previewUpdating = false; + } + } + + private async Task StartRecordingAsync() + { + try + { + 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 + { + var result = await _recording.StopAsync().ConfigureAwait(true); + _timer?.Stop(); + Phase = RecordingPhase.Setup; + ElapsedDisplay = "00:00:00"; + StatusMessage = result.FilePath is null + ? null + : $"Saved to {result.FilePath}"; + RecordingStopped?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + StatusMessage = $"Failed to stop: {ex.Message}"; + } + } + + 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(); + _recording.StateChanged -= OnRecordingStateChanged; + } +} diff --git a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml index 246aa82..38d2253 100644 --- a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml +++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml @@ -16,17 +16,21 @@ - + + + + + + + - + @@ -59,14 +63,17 @@ - + + + - - - - - - - - - - - - - - - - - + + Text="No preview available" /> + diff --git a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs index 04e3ae2..ff2b402 100644 --- a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml.cs @@ -5,10 +5,12 @@ namespace Riverside.Elapsed.App.Views; public sealed partial class RecordingPage : Page { - private const int CompactWidth = 340; - private const int CompactHeight = 460; - private const int ExpandedWidth = 800; - private const int ExpandedHeight = 460; + private const int CompactWidth = 380; + private const int CompactHeight = 520; + private const int ExpandedWidth = 840; + private const int ExpandedHeight = 520; + + private Border? _selectedCard; public RecordingPage() { @@ -28,6 +30,7 @@ private void OnLoaded(object sender, RoutedEventArgs e) ScreenRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Screen; WindowRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Window; + CameraRadio.Checked += (_, _) => vm.SelectedSourceKind = CaptureSourceKind.Camera; } } @@ -40,6 +43,25 @@ private void OnUnloaded(object sender, RoutedEventArgs e) } } + 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 OnRecordingStarted(object? sender, EventArgs e) { SetWindowSize(ExpandedWidth, ExpandedHeight); From 96a4d6e90c936f5a36b3c3939482a36c9e859b3c Mon Sep 17 00:00:00 2001 From: ascpixi <44982772+ascpixi@users.noreply.github.com> Date: Sun, 31 May 2026 23:27:02 -0400 Subject: [PATCH 04/13] Add auth-first flow with Lapse upload and draft submission --- .../Riverside.Elapsed.App/App.xaml.cs | 5 +- .../Models/Recording/RecordingPhase.cs | 1 + .../Recording/ICaptureSourceProvider.cs | 1 + .../Recording/NoOpCaptureSourceProvider.cs | 3 + .../Recording/WindowsCaptureSourceProvider.cs | 53 ++ .../Services/Upload/LapseService.cs | 459 ++++++++++++++++++ .../ViewModels/RecordingViewModel.cs | 182 ++++++- .../Views/RecordingPage.xaml | 127 ++++- .../Views/RecordingPage.xaml.cs | 10 + 9 files changed, 822 insertions(+), 19 deletions(-) create mode 100644 src/platforms/Riverside.Elapsed.App/Services/Upload/LapseService.cs diff --git a/src/platforms/Riverside.Elapsed.App/App.xaml.cs b/src/platforms/Riverside.Elapsed.App/App.xaml.cs index 16bc0b5..35d8760 100644 --- a/src/platforms/Riverside.Elapsed.App/App.xaml.cs +++ b/src/platforms/Riverside.Elapsed.App/App.xaml.cs @@ -1,5 +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; @@ -65,12 +66,14 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) ICaptureSourceProvider sourceProvider = new NoOpCaptureSourceProvider(); #endif + var lapse = new LapseService(); + MainWindow.Content = new RecordingPage { //var authService = services.GetRequiredService(); //await authService.TryRestoreSessionAsync(); await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); - DataContext = new RecordingViewModel(recording, sourceProvider) + DataContext = new RecordingViewModel(recording, sourceProvider, lapse) }; MainWindow.Activate(); } diff --git a/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs b/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs index 1615e5a..2b5dc7a 100644 --- a/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs +++ b/src/platforms/Riverside.Elapsed.App/Models/Recording/RecordingPhase.cs @@ -5,4 +5,5 @@ public enum RecordingPhase Setup, Active, Paused, + Uploading, } diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs index 0c10c9c..e0f0b2a 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/ICaptureSourceProvider.cs @@ -6,4 +6,5 @@ public interface ICaptureSourceProvider { Task> GetSourcesAsync(CaptureSourceKind kind); Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight); + Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight); } diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs index 899f955..60705c6 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/NoOpCaptureSourceProvider.cs @@ -9,4 +9,7 @@ public Task> GetSourcesAsync(CaptureSourceKind kind public Task CapturePreviewAsync(CaptureSource source, int maxWidth, int maxHeight) => Task.FromResult(null); + + public Task CapturePreviewBytesAsync(CaptureSource source, int maxWidth, int maxHeight) + => Task.FromResult(null); } diff --git a/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs index f2e58cd..2813f79 100644 --- a/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs +++ b/src/platforms/Riverside.Elapsed.App/Services/Recording/WindowsCaptureSourceProvider.cs @@ -92,6 +92,59 @@ public Task> GetSourcesAsync(CaptureSourceKind kind return new BitmapImage(new Uri(tempPath)); } + 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() { var results = new List<(CaptureSource, byte[]?, int, int)>(); 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..0b88e8d --- /dev/null +++ b/src/platforms/Riverside.Elapsed.App/Services/Upload/LapseService.cs @@ -0,0 +1,459 @@ +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 async Task SignOutAsync(CancellationToken ct = default) + { + _auth = null; + if (File.Exists(AuthPath)) + File.Delete(AuthPath); + await 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 = new byte[] { 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 static void OpenDraftInBrowser(string draftId) + => OpenUrl($"{DraftEditorBase}/{draftId}"); + + private static void OpenUrl(string url) + { + try + { + Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + } + catch { } + } + + 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 { } + + 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); + using var sha256 = SHA256.Create(); + var challengeBytes = sha256.ComputeHash(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); + return new string(buf.Select(b => chars[b % chars.Length]).ToArray()); + } + + 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, + Complete, +} diff --git a/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs b/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs index 1449d7b..a87bde5 100644 --- a/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs +++ b/src/platforms/Riverside.Elapsed.App/ViewModels/RecordingViewModel.cs @@ -1,8 +1,10 @@ using System.Collections.ObjectModel; 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; @@ -10,6 +12,7 @@ public sealed partial class RecordingViewModel : ObservableObject, IDisposable { private readonly IRecordingFacade _recording; private readonly ICaptureSourceProvider _sourceProvider; + private readonly LapseService _lapse; private readonly DispatcherQueueTimer? _timer; private readonly DispatcherQueueTimer? _previewTimer; private bool _previewUpdating; @@ -32,15 +35,37 @@ public sealed partial class RecordingViewModel : ObservableObject, IDisposable [ObservableProperty] private ImageSource? _previewImage; - public RecordingViewModel(IRecordingFacade recording, ICaptureSourceProvider sourceProvider) + [ObservableProperty] + private double _uploadProgress; + + [ObservableProperty] + private string? _uploadStatusText; + + [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, () => Phase == RecordingPhase.Setup && SelectedSource is not null); + 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); + SignInCommand = new AsyncRelayCommand(SignInAsync, () => !IsSignedIn); + SignOutCommand = new AsyncRelayCommand(SignOutAsync, () => IsSignedIn); + ViewProfileCommand = new RelayCommand(ViewProfile, () => IsSignedIn); var dispatcher = DispatcherQueue.GetForCurrentThread(); if (dispatcher is not null) @@ -54,14 +79,16 @@ public RecordingViewModel(IRecordingFacade recording, ICaptureSourceProvider sou _previewTimer.Tick += (_, _) => _ = RefreshPreviewAsync(); } - _ = RefreshSourcesAsync(); + _ = InitializeAsync(); } public ObservableCollection CurrentSources { get; } = []; public bool IsInSetup => Phase == RecordingPhase.Setup; - public bool IsActive => Phase != RecordingPhase.Setup; + public bool IsActive => Phase is RecordingPhase.Active or RecordingPhase.Paused; + + public bool IsUploading => Phase == RecordingPhase.Uploading; public bool IsPaused => Phase == RecordingPhase.Paused; @@ -75,10 +102,98 @@ public RecordingViewModel(IRecordingFacade recording, ICaptureSourceProvider sou public IAsyncRelayCommand StopCommand { 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 + { + 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(); @@ -95,6 +210,7 @@ partial void OnPhaseChanged(RecordingPhase value) { OnPropertyChanged(nameof(IsInSetup)); OnPropertyChanged(nameof(IsActive)); + OnPropertyChanged(nameof(IsUploading)); OnPropertyChanged(nameof(IsPaused)); OnPropertyChanged(nameof(PauseResumeLabel)); OnPropertyChanged(nameof(PauseResumeGlyph)); @@ -108,12 +224,8 @@ private async Task RefreshSourcesAsync() { CurrentSources.Clear(); var sources = await _sourceProvider.GetSourcesAsync(SelectedSourceKind); - Console.Error.WriteLine($"[Elapsed] {SelectedSourceKind}: {sources.Count} source(s)"); foreach (var source in sources) - { - Console.Error.WriteLine($"[Elapsed] - {source.Name} | thumb={source.Thumbnail is not null}"); CurrentSources.Add(source); - } SelectedSource = null; } @@ -121,7 +233,7 @@ private void UpdatePreviewTimer() { if (_previewTimer is null) return; - if (SelectedSource is not null && Phase != RecordingPhase.Setup) + if (SelectedSource is not null && Phase is RecordingPhase.Active or RecordingPhase.Paused) _previewTimer.Start(); else _previewTimer.Stop(); @@ -139,7 +251,7 @@ private async Task RefreshPreviewAsync() if (image is not null) PreviewImage = image; } - catch { /* capture may fail transiently */ } + catch { } finally { _previewUpdating = false; @@ -189,14 +301,56 @@ private async Task StopAsync() { try { + byte[]? thumbnailBytes = null; + if (SelectedSource is not null) + { + try + { + thumbnailBytes = await _sourceProvider.CapturePreviewBytesAsync(SelectedSource, 640, 480); + } + catch { } + } + var result = await _recording.StopAsync().ConfigureAwait(true); _timer?.Stop(); + _previewTimer?.Stop(); + + if (result.FilePath is null) + { + Phase = RecordingPhase.Setup; + ElapsedDisplay = "00:00:00"; + RecordingStopped?.Invoke(this, EventArgs.Empty); + return; + } + + Phase = RecordingPhase.Uploading; + RecordingStopped?.Invoke(this, EventArgs.Empty); + + try + { + var progress = new Progress(p => + { + UploadProgress = p.Fraction; + UploadStatusText = p.Description; + }); + + var draftId = await _lapse.UploadDraftAsync( + result.FilePath, + thumbnailBytes ?? [], + result.Duration, + progress); + + LapseService.OpenDraftInBrowser(draftId); + } + catch (Exception ex) + { + StatusMessage = $"Upload failed: {ex.Message}"; + } + Phase = RecordingPhase.Setup; ElapsedDisplay = "00:00:00"; - StatusMessage = result.FilePath is null - ? null - : $"Saved to {result.FilePath}"; - RecordingStopped?.Invoke(this, EventArgs.Empty); + UploadProgress = 0; + UploadStatusText = null; } catch (Exception ex) { diff --git a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml index 38d2253..ec148be 100644 --- a/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml +++ b/src/platforms/Riverside.Elapsed.App/Views/RecordingPage.xaml @@ -27,9 +27,10 @@ + - + @@ -51,6 +52,20 @@ VerticalAlignment="Center" Text="Elapsed" /> + + + + + @@ -162,13 +211,42 @@ + + + + + + + +