From 82ffc81a9879dfa2b61232cc22157659f46c831b Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Thu, 2 Apr 2026 11:28:55 +0200 Subject: [PATCH] Make settings runtime state honest --- .../Media/Models/MediaSceneState.cs | 2 +- .../Workspace/Models/StudioSettings.cs | 2 +- .../PrompterOneServiceCollectionExtensions.cs | 3 + src/PrompterOne.Shared/Contracts/UiDomIds.cs | 3 + src/PrompterOne.Shared/Contracts/UiTestIds.cs | 8 + .../Components/SettingsAiProviderCard.razor | 86 ++ .../Components/SettingsAiSection.razor | 260 ++-- .../Components/SettingsAiSection.razor.cs | 151 ++ .../Components/SettingsCamerasSection.razor | 206 +++ .../Components/SettingsCloudSection.razor | 4 +- .../Components/SettingsCloudSection.razor.cs | 3 +- .../Components/SettingsFilesSection.razor | 118 +- .../Components/SettingsFilesSection.razor.cs | 104 ++ .../SettingsMicrophoneLevelCard.razor | 61 +- .../SettingsMicrophonesSection.razor | 257 ++++ .../Settings/Models/AiProviderSettings.cs | 47 + .../Models/BrowserFileStorageSettings.cs | 27 + .../Models/SettingsPagePreferences.cs | 26 +- .../Settings/Pages/SettingsPage.Cameras.cs | 196 +++ .../Settings/Pages/SettingsPage.MediaState.cs | 288 ++++ .../Pages/SettingsPage.Microphones.cs | 245 ++++ .../Settings/Pages/SettingsPage.Navigation.cs | 1 + .../Pages/SettingsPage.Preferences.cs | 35 +- .../Settings/Pages/SettingsPage.razor | 1273 +---------------- .../Services/AiProviderSettingsStore.cs | 21 + .../Services/BrowserFileStorageStore.cs | 129 ++ .../Storage/Cloud/CloudStorageModels.cs | 8 +- .../Cloud/CloudStorageTransferService.cs | 11 +- .../Storage/PrompterStorageDefaults.cs | 5 +- .../design/modules/settings/20-reference.css | 44 +- .../Settings/SettingsInteractionTests.cs | 39 +- .../Support/TestSupport.cs | 2 + .../Settings/SettingsCloudStorageFlowTests.cs | 26 +- .../Support/BrowserTestConstants.cs | 17 + .../TeleprompterSettingsFlowTests.cs | 4 +- 35 files changed, 2136 insertions(+), 1576 deletions(-) create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsAiProviderCard.razor create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor.cs create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsCamerasSection.razor create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor.cs create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsMicrophonesSection.razor create mode 100644 src/PrompterOne.Shared/Settings/Models/AiProviderSettings.cs create mode 100644 src/PrompterOne.Shared/Settings/Models/BrowserFileStorageSettings.cs create mode 100644 src/PrompterOne.Shared/Settings/Pages/SettingsPage.Cameras.cs create mode 100644 src/PrompterOne.Shared/Settings/Pages/SettingsPage.MediaState.cs create mode 100644 src/PrompterOne.Shared/Settings/Pages/SettingsPage.Microphones.cs create mode 100644 src/PrompterOne.Shared/Settings/Services/AiProviderSettingsStore.cs create mode 100644 src/PrompterOne.Shared/Settings/Services/BrowserFileStorageStore.cs diff --git a/src/PrompterOne.Core/Media/Models/MediaSceneState.cs b/src/PrompterOne.Core/Media/Models/MediaSceneState.cs index 9fbd53a..8ac4950 100644 --- a/src/PrompterOne.Core/Media/Models/MediaSceneState.cs +++ b/src/PrompterOne.Core/Media/Models/MediaSceneState.cs @@ -6,7 +6,7 @@ public sealed record MediaSourceTransform( double Width = 0.32, double Height = 0.32, double Rotation = 0, - bool MirrorHorizontal = true, + bool MirrorHorizontal = false, bool MirrorVertical = false, bool Visible = true, bool IncludeInOutput = true, diff --git a/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs b/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs index 5491df6..fb4c7ed 100644 --- a/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs +++ b/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs @@ -37,7 +37,7 @@ public sealed record CameraStudioSettings( string? DefaultCameraId = null, CameraResolutionPreset Resolution = CameraResolutionPreset.FullHd1080, CameraFrameRatePreset FrameRate = CameraFrameRatePreset.Fps30, - bool MirrorCamera = true, + bool MirrorCamera = false, bool AutoStartOnRead = true); public sealed record MicrophoneStudioSettings( diff --git a/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs b/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs index 3a89ed1..7f74eb6 100644 --- a/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs +++ b/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using PrompterOne.Core.Services.Workspace; using PrompterOne.Shared.Services.Diagnostics; using PrompterOne.Shared.Services.Editor; +using PrompterOne.Shared.Settings.Services; using PrompterOne.Shared.Storage; using PrompterOne.Shared.Storage.Cloud; @@ -59,7 +60,9 @@ public static IServiceCollection AddPrompterOneShared(this IServiceCollection se services.AddScoped(); services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/PrompterOne.Shared/Contracts/UiDomIds.cs b/src/PrompterOne.Shared/Contracts/UiDomIds.cs index a4e4597..bc5b5f5 100644 --- a/src/PrompterOne.Shared/Contracts/UiDomIds.cs +++ b/src/PrompterOne.Shared/Contracts/UiDomIds.cs @@ -21,6 +21,9 @@ public static class Settings { public const string CameraPreviewVideo = "settings-camera-preview-video"; public const string MicrophoneLevelMonitor = "settings-microphone-level-monitor"; + + public static string MicrophoneLevelMonitorForDevice(string deviceId) => + $"{MicrophoneLevelMonitor}-{deviceId}"; } public static class GoLive diff --git a/src/PrompterOne.Shared/Contracts/UiTestIds.cs b/src/PrompterOne.Shared/Contracts/UiTestIds.cs index 89ec46c..580b741 100644 --- a/src/PrompterOne.Shared/Contracts/UiTestIds.cs +++ b/src/PrompterOne.Shared/Contracts/UiTestIds.cs @@ -297,6 +297,12 @@ public static class Settings public static string AiProvider(string providerId) => $"settings-ai-provider-{providerId}"; + public static string AiProviderClear(string providerId) => $"settings-ai-provider-{providerId}-clear"; + + public static string AiProviderMessage(string providerId) => $"settings-ai-provider-{providerId}-message"; + + public static string AiProviderSave(string providerId) => $"settings-ai-provider-{providerId}-save"; + public static string CameraDevice(string deviceId) => $"settings-camera-device-{deviceId}"; public static string CameraDeviceAction(string deviceId) => $"settings-camera-device-action-{deviceId}"; @@ -462,6 +468,8 @@ public static class GoLive public static string ProviderCard(string providerId) => $"go-live-provider-{providerId}"; public static string ProviderSourcePicker(string providerId) => $"go-live-provider-sources-{providerId}"; public static string ProviderSourceSummary(string providerId) => $"go-live-provider-source-summary-{providerId}"; + public static string RuntimeMetric(string metricId) => $"go-live-runtime-metric-{metricId}"; + public static string StatusMetric(string metricId) => $"go-live-status-metric-{metricId}"; public static string ProviderSourceToggle(string providerId, string sourceId) => $"go-live-provider-source-{providerId}-{sourceId}"; public static string SourceCamera(string sourceId) => $"go-live-source-camera-{sourceId}"; diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiProviderCard.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAiProviderCard.razor new file mode 100644 index 0000000..df7f9d8 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiProviderCard.razor @@ -0,0 +1,86 @@ +@namespace PrompterOne.Shared.Components.Settings + + + + @if (Icon is not null) + { + @Icon + } + + + + @if (ChildContent is not null) + { + @ChildContent + } + +
+ + +
+ + @if (!string.IsNullOrWhiteSpace(Message)) + { +

+ @Message +

+ } +
+
+ +@code { + [Parameter] public string AdditionalCssClass { get; set; } = string.Empty; + + [Parameter] public RenderFragment? ChildContent { get; set; } + + [Parameter] public EventCallback Clear { get; set; } + + [Parameter] public string ClearTestId { get; set; } = string.Empty; + + [Parameter] public RenderFragment? Icon { get; set; } + + [Parameter] public string IconStyle { get; set; } = string.Empty; + + [Parameter] public bool IsOpen { get; set; } + + [Parameter] public string Message { get; set; } = string.Empty; + + [Parameter] public string MessageTestId { get; set; } = string.Empty; + + [Parameter] public EventCallback OnToggle { get; set; } + + [Parameter] public EventCallback Save { get; set; } + + [Parameter] public string SaveTestId { get; set; } = string.Empty; + + [Parameter] public string StatusClass { get; set; } = string.Empty; + + [Parameter] public string StatusLabel { get; set; } = string.Empty; + + [Parameter] public string Subtitle { get; set; } = string.Empty; + + [Parameter] public string TestId { get; set; } = string.Empty; + + [Parameter] public string Title { get; set; } = string.Empty; +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor index 0587faa..38f6fc4 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor @@ -8,170 +8,124 @@ style="@DisplayStyle" data-testid="@UiTestIds.Settings.AiPanel">

@SettingsNavigationText.AiProviderLabel

-

Connect an AI provider to enable rewriting, expanding, and formatting scripts with AI.

+

Store AI provider drafts locally in this browser. Runtime connection testing is not implemented yet, so every provider stays unconnected until a real integration ships.

- - - - -
-
- - + OnToggle="@(() => ToggleCard.InvokeAsync(ClaudeApiCardId))" + TestId="@UiTestIds.Settings.AiProvider(SettingsAiProviderIds.ClaudeApi)" + Save="SaveClaudeAsync" + Clear="ClearClaudeAsync" + SaveTestId="@UiTestIds.Settings.AiProviderSave(SettingsAiProviderIds.ClaudeApi)" + ClearTestId="@UiTestIds.Settings.AiProviderClear(SettingsAiProviderIds.ClaudeApi)" + Message="@GetMessage(SettingsAiProviderIds.ClaudeApi)" + MessageTestId="@UiTestIds.Settings.AiProviderMessage(SettingsAiProviderIds.ClaudeApi)"> + + + + + + +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
- - -
-
-
- - - - - - Connected - -
- + + - - - - - -
-
- - + OnToggle="@(() => ToggleCard.InvokeAsync(OpenAiCardId))" + TestId="@UiTestIds.Settings.AiProvider(SettingsAiProviderIds.OpenAi)" + Save="SaveOpenAiAsync" + Clear="ClearOpenAiAsync" + SaveTestId="@UiTestIds.Settings.AiProviderSave(SettingsAiProviderIds.OpenAi)" + ClearTestId="@UiTestIds.Settings.AiProviderClear(SettingsAiProviderIds.OpenAi)" + Message="@GetMessage(SettingsAiProviderIds.OpenAi)" + MessageTestId="@UiTestIds.Settings.AiProviderMessage(SettingsAiProviderIds.OpenAi)"> + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
- - + + - - - - - - -
-
- - -
-
- - + OnToggle="@(() => ToggleCard.InvokeAsync(OllamaCardId))" + TestId="@UiTestIds.Settings.AiProvider(SettingsAiProviderIds.Ollama)" + Save="SaveOllamaAsync" + Clear="ClearOllamaAsync" + SaveTestId="@UiTestIds.Settings.AiProviderSave(SettingsAiProviderIds.Ollama)" + ClearTestId="@UiTestIds.Settings.AiProviderClear(SettingsAiProviderIds.Ollama)" + Message="@GetMessage(SettingsAiProviderIds.Ollama)" + MessageTestId="@UiTestIds.Settings.AiProviderMessage(SettingsAiProviderIds.Ollama)"> + + + + + + + + +
+
+ + +
+
+ + +
-
- - + +
- -@code { - private static readonly IReadOnlyList ClaudeModelOptions = - [ - new("claude-sonnet-4-6", "claude-sonnet-4-6"), - new("claude-opus-4-6", "claude-opus-4-6"), - new("claude-haiku-4-5", "claude-haiku-4-5"), - ]; - - private static readonly IReadOnlyList OpenAiModelOptions = - [ - new("gpt-4o", "gpt-4o"), - new("gpt-4o-mini", "gpt-4o-mini"), - new("o1", "o1"), - new("o3-mini", "o3-mini"), - ]; - - private const string ActiveCssClass = "active"; - private const string ClaudeApiCardId = "ai-claude-api"; - private const string DisconnectedStatusClass = ""; - private const string DisconnectedStatusLabel = "Not configured"; - private const string OllamaCardId = "ai-ollama"; - private const string OpenAiCardId = "ai-openai"; - private const string SelectedStatusClass = "set-dest-ok"; - private const string SelectedStatusLabel = "Active"; - - [Parameter] public string DisplayStyle { get; set; } = string.Empty; - [Parameter] public Func IsCardOpen { get; set; } = static _ => false; - [Parameter] public string SelectedProviderId { get; set; } = string.Empty; - [Parameter] public EventCallback SelectProvider { get; set; } - [Parameter] public EventCallback ToggleCard { get; set; } - - private string BuildCardCssClass(string providerId) => - IsSelected(providerId) ? ActiveCssClass : string.Empty; - - private string BuildStatusClass(string providerId) => - IsSelected(providerId) ? SelectedStatusClass : DisconnectedStatusClass; - - private string BuildStatusLabel(string providerId, bool isConfiguredByDefault) => - IsSelected(providerId) - ? SelectedStatusLabel - : isConfiguredByDefault ? "Ready" : DisconnectedStatusLabel; - - private bool IsSelected(string providerId) => - string.Equals(providerId, SelectedProviderId, StringComparison.Ordinal); - - private async Task OnProviderCardToggleAsync(string providerId, string cardId) - { - await SelectProvider.InvokeAsync(providerId); - await ToggleCard.InvokeAsync(cardId); - } -} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor.cs b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor.cs new file mode 100644 index 0000000..2b23046 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Components; +using PrompterOne.Shared.Pages; +using PrompterOne.Shared.Settings.Components; +using PrompterOne.Shared.Settings.Models; +using PrompterOne.Shared.Settings.Services; + +namespace PrompterOne.Shared.Components.Settings; + +public partial class SettingsAiSection : ComponentBase +{ + private const string ActiveCssClass = "active"; + private const string ClaudeApiCardId = "ai-claude-api"; + private const string DisconnectedStatusClass = "set-dest-idle"; + private const string DisconnectedStatusLabel = "Not configured"; + private const string LocalOnlySavedMessage = "Saved locally in this browser. Runtime connection testing is not available yet."; + private const string LocalStatusClass = "set-dest-local"; + private const string LocalStatusLabel = "Saved locally"; + private const string OllamaCardId = "ai-ollama"; + private const string OpenAiCardId = "ai-openai"; + private const string ProviderClearedMessage = "Provider draft cleared from this browser."; + + private static readonly IReadOnlyList ClaudeModelOptions = + [ + new("claude-sonnet-4-6", "claude-sonnet-4-6"), + new("claude-opus-4-6", "claude-opus-4-6"), + new("claude-haiku-4-5", "claude-haiku-4-5"), + ]; + + private static readonly IReadOnlyList OpenAiModelOptions = + [ + new("gpt-4o", "gpt-4o"), + new("gpt-4o-mini", "gpt-4o-mini"), + new("o1", "o1"), + new("o3-mini", "o3-mini"), + ]; + + private readonly Dictionary _messages = new(StringComparer.Ordinal); + private AiProviderSettings _settings = AiProviderSettings.CreateDefault(); + + [Inject] private AiProviderSettingsStore SettingsStore { get; set; } = null!; + + [Parameter] public string DisplayStyle { get; set; } = string.Empty; + + [Parameter] public Func IsCardOpen { get; set; } = static _ => false; + + [Parameter] public EventCallback ToggleCard { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + _settings = await SettingsStore.LoadAsync(); + await InvokeAsync(StateHasChanged); + } + + private static string BuildCardCssClass(AnthropicAiProviderSettings settings) => + IsConfigured(settings) ? ActiveCssClass : string.Empty; + + private static string BuildCardCssClass(OpenAiProviderSettings settings) => + IsConfigured(settings) ? ActiveCssClass : string.Empty; + + private static string BuildCardCssClass(OllamaAiProviderSettings settings) => + IsConfigured(settings) ? ActiveCssClass : string.Empty; + + private static string BuildStatusClass(AnthropicAiProviderSettings settings) => + IsConfigured(settings) ? LocalStatusClass : DisconnectedStatusClass; + + private static string BuildStatusClass(OpenAiProviderSettings settings) => + IsConfigured(settings) ? LocalStatusClass : DisconnectedStatusClass; + + private static string BuildStatusClass(OllamaAiProviderSettings settings) => + IsConfigured(settings) ? LocalStatusClass : DisconnectedStatusClass; + + private static string BuildStatusLabel(AnthropicAiProviderSettings settings) => + IsConfigured(settings) ? LocalStatusLabel : DisconnectedStatusLabel; + + private static string BuildStatusLabel(OpenAiProviderSettings settings) => + IsConfigured(settings) ? LocalStatusLabel : DisconnectedStatusLabel; + + private static string BuildStatusLabel(OllamaAiProviderSettings settings) => + IsConfigured(settings) ? LocalStatusLabel : DisconnectedStatusLabel; + + private string GetMessage(string providerId) => + _messages.GetValueOrDefault(providerId) ?? string.Empty; + + private static bool IsConfigured(AnthropicAiProviderSettings settings) => + !string.IsNullOrWhiteSpace(settings.ApiKey) && + !string.IsNullOrWhiteSpace(settings.Model); + + private static bool IsConfigured(OpenAiProviderSettings settings) => + !string.IsNullOrWhiteSpace(settings.ApiKey) && + !string.IsNullOrWhiteSpace(settings.Model); + + private static bool IsConfigured(OllamaAiProviderSettings settings) => + !string.IsNullOrWhiteSpace(settings.Endpoint) && + !string.IsNullOrWhiteSpace(settings.Model); + + private Task OnClaudeModelChanged(ChangeEventArgs args) + { + _settings.ClaudeApi.Model = args.Value?.ToString() ?? string.Empty; + return Task.CompletedTask; + } + + private Task OnOpenAiModelChanged(ChangeEventArgs args) + { + _settings.OpenAi.Model = args.Value?.ToString() ?? string.Empty; + return Task.CompletedTask; + } + + private async Task SaveClaudeAsync() + { + await SettingsStore.SaveAsync(_settings); + _messages[SettingsAiProviderIds.ClaudeApi] = LocalOnlySavedMessage; + } + + private async Task SaveOpenAiAsync() + { + await SettingsStore.SaveAsync(_settings); + _messages[SettingsAiProviderIds.OpenAi] = LocalOnlySavedMessage; + } + + private async Task SaveOllamaAsync() + { + await SettingsStore.SaveAsync(_settings); + _messages[SettingsAiProviderIds.Ollama] = LocalOnlySavedMessage; + } + + private async Task ClearClaudeAsync() + { + _settings.ClaudeApi = new AnthropicAiProviderSettings(); + _messages[SettingsAiProviderIds.ClaudeApi] = ProviderClearedMessage; + await SettingsStore.SaveAsync(_settings); + } + + private async Task ClearOpenAiAsync() + { + _settings.OpenAi = new OpenAiProviderSettings(); + _messages[SettingsAiProviderIds.OpenAi] = ProviderClearedMessage; + await SettingsStore.SaveAsync(_settings); + } + + private async Task ClearOllamaAsync() + { + _settings.Ollama = new OllamaAiProviderSettings(); + _messages[SettingsAiProviderIds.Ollama] = ProviderClearedMessage; + await SettingsStore.SaveAsync(_settings); + } +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsCamerasSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsCamerasSection.razor new file mode 100644 index 0000000..dbee391 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsCamerasSection.razor @@ -0,0 +1,206 @@ +@namespace PrompterOne.Shared.Components.Settings +@using PrompterOne.Core.Models.Media +@using PrompterOne.Core.Models.Workspace +@using PrompterOne.Shared.Services +@using PrompterOne.Shared.Settings.Components + +
+

Cameras

+

Choose which cameras are available in GO LIVE and select the primary stream camera.

+ +
+

Device Access

+ +
+ +
+
+ @if (Cameras.Count == 0) + { +
+ No camera devices are currently visible to the browser. +
+ } + else + { + @foreach (var camera in Cameras) + { + var sceneCamera = ResolveSceneCamera(camera.DeviceId); + var isEnabled = sceneCamera is not null; + var isPrimary = IsPrimaryCamera(camera); + var isPreview = IsPreviewCamera(camera); +
+
+
+
+ @if (isPrimary) + { + + + + + + Primary + + } +
+
+ @MediaDeviceLabelSanitizer.Sanitize(camera.Label) + @BuildCameraMeta(camera, isPrimary) +
+
+
+
+
+
+
+ @if (isEnabled && sceneCamera is not null) + { +
+
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+ } +
+ } + } + +
+

General

+
+
+ +
+
+
+
+
+
+
+ + +
+
+ +@code { + private const string PrimaryCameraButtonActiveCssClass = "set-btn-golden set-btn-sm set-btn-primary-cam active"; + private const string PrimaryCameraButtonCssClass = "set-btn-outline set-btn-sm set-btn-primary-cam"; + private const string PrimaryCameraLabel = "Primary Camera"; + private const string SetPrimaryCameraLabel = "Set as Primary"; + + [Parameter] public bool AllSceneCamerasIncludedInOutput { get; set; } + + [Parameter] public IReadOnlyList CameraResolutionOptions { get; set; } = []; + + [Parameter] public string CameraPreviewDescription { get; set; } = string.Empty; + + [Parameter] public EventCallback CameraResolutionChanged { get; set; } + + [Parameter] public bool CameraAccessGranted { get; set; } + + [Parameter] public IReadOnlyList Cameras { get; set; } = []; + + [Parameter] public string DisplayStyle { get; set; } = string.Empty; + + [Parameter] public EventCallback RequestMediaAccess { get; set; } + + [Parameter] public bool IsSectionActive { get; set; } + + [Parameter] public string MediaAccessActionLabel { get; set; } = string.Empty; + + [Parameter] public MediaDeviceInfo? PreviewCamera { get; set; } + + [Parameter] public SceneCameraSource? PreviewSceneCamera { get; set; } + + [Parameter] public Func BuildCameraFeedCssClass { get; set; } = _ => string.Empty; + + [Parameter] public Func BuildCameraCardCssClass { get; set; } = (_, _) => string.Empty; + + [Parameter] public Func BuildCameraMeta { get; set; } = (_, _) => string.Empty; + + [Parameter] public Func BuildToggleCssClass { get; set; } = _ => string.Empty; + + [Parameter] public EventCallback SelectCameraPreview { get; set; } + + [Parameter] public string SelectedCameraId { get; set; } = string.Empty; + + [Parameter] public Func ResolveSceneCamera { get; set; } = _ => null; + + [Parameter] public EventCallback SetPrimaryCamera { get; set; } + + [Parameter] public StudioSettings StudioSettings { get; set; } = StudioSettings.Default; + + [Parameter] public EventCallback ToggleCameraSelection { get; set; } + + [Parameter] public EventCallback ToggleAllSceneOutputs { get; set; } + + [Parameter] public EventCallback ToggleReaderCameraScene { get; set; } + + [Parameter] public EventCallback ToggleSceneMirror { get; set; } + + [Parameter] public bool ReaderCameraEnabled { get; set; } + + private string BuildMirrorTestId(bool isPrimary, string sourceId) => + isPrimary ? UiTestIds.Settings.CameraMirrorToggle : UiTestIds.Settings.SceneMirror(sourceId); + + private static string BuildPrimaryCameraButtonCssClass(bool isPrimary) => + isPrimary ? PrimaryCameraButtonActiveCssClass : PrimaryCameraButtonCssClass; + + private bool IsPrimaryCamera(MediaDeviceInfo camera) => + string.Equals(SelectedCameraId, camera.DeviceId, StringComparison.Ordinal); + + private bool IsPreviewCamera(MediaDeviceInfo camera) => + string.Equals(PreviewCamera?.DeviceId, camera.DeviceId, StringComparison.Ordinal); +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor index 6c323b4..3778bab 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor @@ -8,7 +8,7 @@ style="@DisplayStyle" data-testid="@UiTestIds.Settings.CloudPanel">

Cloud Storage

-

Connect ManagedCode storage providers to import or save scripts and settings. Provider keys stay in this browser local storage.

+

Configure optional cloud snapshot targets here. Provider credentials stay in this browser local storage, and nothing is connected until you save valid real credentials.

Sync Defaults

@@ -37,7 +37,7 @@
-

Local scripts and folders live in the browser storage virtual file system. Only provider credentials are persisted in browser local storage.

+

The script library stays browser-local by default. Cloud providers are explicit import/export targets, and their credentials stay in browser local storage.

diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor.cs b/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor.cs index cf65654..2375a2a 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor.cs +++ b/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor.cs @@ -11,6 +11,7 @@ public partial class SettingsCloudSection : ComponentBase { private const string ConnectedStatusClass = "set-dest-ok"; private const string ConnectedStatusLabel = "Connected"; + private const string DisconnectedStatusClass = "set-dest-idle"; private const string DisconnectedSubtitle = "Not connected"; private const string DisconnectedStatusLabel = "Disconnected"; private const string OnCssClass = "on"; @@ -240,7 +241,7 @@ private static string BuildToggleCssClass(bool isOn) => isOn ? $"{SetToggleCssClass} {OnCssClass}" : SetToggleCssClass; private static string GetStatusClass(CloudStorageConnectionState connection) => - connection.IsConnected ? ConnectedStatusClass : string.Empty; + connection.IsConnected ? ConnectedStatusClass : DisconnectedStatusClass; private static string GetStatusLabel(CloudStorageConnectionState connection) => connection.IsConnected ? ConnectedStatusLabel : DisconnectedStatusLabel; diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor index f6ff424..1fb5d56 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor @@ -7,12 +7,12 @@ style="@DisplayStyle" data-testid="@UiTestIds.Settings.FilesPanel">

@SettingsNavigationText.FileStorageLabel

-

Where scripts, recordings, and exports are saved locally.

+

PrompterOne is browser-only. Scripts stay in the browser library store, while recordings and exports use the browser-local storage container instead of fake desktop folders.

@@ -23,26 +23,28 @@
- -
- - -
+ + +
+
+ +
+

@_viewState.Scripts.DetailLabel

-
-
@@ -51,8 +53,8 @@ @@ -61,24 +63,26 @@
- -
- - -
+ + +
+
+ +
-
+

@_viewState.Recordings.DetailLabel

@@ -89,74 +93,20 @@
-
- -
- - -
+ +
+
+ + +
+

@_viewState.Exports.DetailLabel

- -@code { - private static readonly IReadOnlyList ExportFormatOptions = - [ - new("TPS (Native)", "TPS (Native)"), - new("Markdown", "Markdown"), - new("Plain Text", "Plain Text"), - new("PDF", "PDF"), - ]; - - private static readonly IReadOnlyList StorageLimitOptions = - [ - new("No limit", "No limit"), - new("10 GB", "10 GB"), - new("50 GB", "50 GB"), - new("100 GB", "100 GB"), - ]; - - private const string ExportsCardId = "files-exports"; - private const string RecordingsCardId = "files-recordings"; - private const string ScriptsCardId = "files-scripts"; - - [Parameter] public string DisplayStyle { get; set; } = string.Empty; - - [Parameter] public string ExportFormat { get; set; } = string.Empty; - - [Parameter] public string ExportsLocation { get; set; } = string.Empty; - - [Parameter] public string FileAutoSaveToggleCssClass { get; set; } = string.Empty; - - [Parameter] public string FileBackupCopiesToggleCssClass { get; set; } = string.Empty; - - [Parameter] public Func IsCardOpen { get; set; } = static _ => false; - - [Parameter] public string RecordingsLocation { get; set; } = string.Empty; - - [Parameter] public string RecordingsStorageLimit { get; set; } = string.Empty; - - [Parameter] public string ScriptsLocation { get; set; } = string.Empty; - - [Parameter] public EventCallback ToggleCard { get; set; } - - [Parameter] public EventCallback ToggleAutoSave { get; set; } - - [Parameter] public EventCallback ToggleBackupCopies { get; set; } - - [Parameter] public EventCallback UpdateExportFormat { get; set; } - - [Parameter] public EventCallback UpdateRecordingsStorageLimit { get; set; } - - private Task OnExportFormatChanged(ChangeEventArgs args) => - UpdateExportFormat.InvokeAsync(args.Value?.ToString() ?? string.Empty); - - private Task OnStorageLimitChanged(ChangeEventArgs args) => - UpdateRecordingsStorageLimit.InvokeAsync(args.Value?.ToString() ?? string.Empty); -} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor.cs b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor.cs new file mode 100644 index 0000000..65fbc00 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Components; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Settings.Components; +using PrompterOne.Shared.Settings.Models; +using PrompterOne.Shared.Settings.Services; +using PrompterOne.Shared.Storage; + +namespace PrompterOne.Shared.Components.Settings; + +public partial class SettingsFilesSection : ComponentBase +{ + private static readonly IReadOnlyList ExportFormatOptions = + [ + new("TPS (Native)", "TPS (Native)"), + new("Markdown", "Markdown"), + new("Plain Text", "Plain Text"), + new("PDF", "PDF"), + ]; + + private static readonly IReadOnlyList StorageLimitOptions = + [ + new("No limit", "No limit"), + new("10 GB", "10 GB"), + new("50 GB", "50 GB"), + new("100 GB", "100 GB"), + ]; + + private const string ExportsCardId = "files-exports"; + private const string OnCssClass = "on"; + private const string RecordingsCardId = "files-recordings"; + private const string ScriptsCardId = "files-scripts"; + private const string SetToggleCssClass = "set-toggle"; + + private BrowserFileStorageSettings _settings = BrowserFileStorageSettings.Default; + private BrowserFileStorageViewState _viewState = new( + Scripts: new FileStorageCardState( + Subtitle: "0 scripts · 0 folders", + ScopeLabel: "Browser JSON library store", + LocationLabel: $"{BrowserStorageKeys.DocumentLibrary} / {BrowserStorageKeys.FolderLibrary}", + DetailLabel: "Authoritative day-to-day script and folder persistence stays in browser storage, not on a desktop filesystem path."), + Recordings: new FileStorageCardState( + Subtitle: $"{PrompterStorageDefaults.BrowserContainerDisplayPrefix}{PrompterStorageDefaults.RecordingsDirectoryPath} · No files yet", + ScopeLabel: "ManagedCode browser container", + LocationLabel: $"{PrompterStorageDefaults.BrowserContainerDisplayPrefix}{PrompterStorageDefaults.RecordingsDirectoryPath}", + DetailLabel: "PrompterOne provisions this browser-local container path for recording artifacts."), + Exports: new FileStorageCardState( + Subtitle: $"{PrompterStorageDefaults.BrowserContainerDisplayPrefix}{PrompterStorageDefaults.ExportDirectoryPath} · No files yet", + ScopeLabel: "ManagedCode.Storage browser VFS", + LocationLabel: $"{PrompterStorageDefaults.BrowserContainerDisplayPrefix}{PrompterStorageDefaults.ExportDirectoryPath}", + DetailLabel: "Exports are written to the browser-local container instead of a fake desktop Downloads folder.")); + + [Inject] private BrowserFileStorageStore FileStorageStore { get; set; } = null!; + + [Parameter] public string DisplayStyle { get; set; } = string.Empty; + + [Parameter] public Func IsCardOpen { get; set; } = static _ => false; + + [Parameter] public EventCallback ToggleCard { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + _settings = await FileStorageStore.LoadSettingsAsync(); + _viewState = await FileStorageStore.LoadViewStateAsync(); + await InvokeAsync(StateHasChanged); + } + + private static string BuildToggleCssClass(bool isOn) => + isOn ? $"{SetToggleCssClass} {OnCssClass}" : SetToggleCssClass; + + private Task OnExportFormatChanged(ChangeEventArgs args) => + UpdateExportFormatAsync(args.Value?.ToString() ?? string.Empty); + + private Task OnStorageLimitChanged(ChangeEventArgs args) => + UpdateRecordingsStorageLimitAsync(args.Value?.ToString() ?? string.Empty); + + private async Task ToggleAutoSaveAsync() + { + _settings = _settings with { FileAutoSaveEnabled = !_settings.FileAutoSaveEnabled }; + await FileStorageStore.SaveSettingsAsync(_settings); + } + + private async Task ToggleBackupCopiesAsync() + { + _settings = _settings with { FileBackupCopiesEnabled = !_settings.FileBackupCopiesEnabled }; + await FileStorageStore.SaveSettingsAsync(_settings); + } + + private async Task UpdateExportFormatAsync(string value) + { + _settings = _settings with { ExportFormat = value }; + await FileStorageStore.SaveSettingsAsync(_settings); + } + + private async Task UpdateRecordingsStorageLimitAsync(string value) + { + _settings = _settings with { RecordingsStorageLimit = value }; + await FileStorageStore.SaveSettingsAsync(_settings); + } +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor b/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor index fb00878..767248e 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor @@ -6,10 +6,10 @@ @inject Microsoft.Extensions.Logging.ILogger Logger @inject MicrophoneLevelInterop MicrophoneLevelInterop -
+
-
+
@@ -17,14 +17,14 @@
-
-
-
+
+
+
+ data-testid="@LabelTestId"> @(CanRenderMonitor ? MicrophoneLabel : EmptyTitle) @MonitorDescription @@ -43,11 +43,11 @@ @if (CanRenderMonitor) { -
+ data-testid="@MeterTestId">
@@ -58,7 +58,7 @@ else {
+ data-testid="@EmptyTestId">
@EmptyDescription
@@ -93,8 +93,12 @@ [Parameter] public bool AccessGranted { get; set; } + [Parameter] public string? CardTestId { get; set; } = UiTestIds.Settings.MicPreviewCard; + [Parameter] public bool IsActive { get; set; } + [Parameter] public bool IsEnabled { get; set; } = true; + [Parameter] public MediaDeviceInfo? Microphone { get; set; } [Parameter] public RenderFragment? ChildContent { get; set; } @@ -103,11 +107,22 @@ [Parameter] public RenderFragment? HeaderAction { get; set; } + [Parameter] public string? EmptyTestId { get; set; } = UiTestIds.Settings.MicPreviewEmpty; + + [Parameter] public string? LabelTestId { get; set; } = UiTestIds.Settings.MicPreviewLabel; + [Parameter] public string MetaText { get; set; } = string.Empty; + [Parameter] public string? MeterTestId { get; set; } = UiTestIds.Settings.MicPreviewMeter; + + [Parameter] public string? MonitorElementId { get; set; } + [Parameter] public string Title { get; set; } = DefaultTitle; - private bool CanMonitor => AccessGranted && IsActive && !string.IsNullOrWhiteSpace(Microphone?.DeviceId); + private string CardCssClass => + IsEnabled ? "set-device-card active" : "set-device-card"; + + private bool CanMonitor => AccessGranted && IsActive && IsEnabled && !string.IsNullOrWhiteSpace(Microphone?.DeviceId); private bool CanRenderMonitor => AccessGranted && !string.IsNullOrWhiteSpace(Microphone?.DeviceId); @@ -134,11 +149,16 @@ private string MonitorPeakStyle => string.Format(CultureInfo.InvariantCulture, "left: {0}%;", Math.Clamp(_levelPercent, 2, 100)); - private string MonitorState => _levelPercent > 0 ? ActiveStateValue : IdleStateValue; + private string MonitorState => _levelPercent > 0 && IsEnabled ? ActiveStateValue : IdleStateValue; private string MonitorValueText => string.Concat(ConvertLevelToDb(_levelPercent).ToString(CultureInfo.InvariantCulture), " dB"); + private string ResolvedMonitorElementId => + string.IsNullOrWhiteSpace(MonitorElementId) + ? UiDomIds.Settings.MicrophoneLevelMonitor + : MonitorElementId; + private string? _attachedDeviceId; private DotNetObjectReference? _observerReference; private int _levelPercent; @@ -162,10 +182,7 @@ await StopMonitoringAsync(); _attachedDeviceId = nextDeviceId; _observerReference ??= DotNetObjectReference.Create(new MicrophoneLevelObserver(HandleLevelChangedAsync)); - await MicrophoneLevelInterop.StartAsync( - UiDomIds.Settings.MicrophoneLevelMonitor, - nextDeviceId, - _observerReference); + await MicrophoneLevelInterop.StartAsync(ResolvedMonitorElementId, nextDeviceId, _observerReference); } catch (Exception exception) { @@ -181,11 +198,11 @@ _observerReference?.Dispose(); } - private static string BuildMicBarStyle(double ratio) => - string.Format( - CultureInfo.InvariantCulture, - "height:{0}%", - Math.Round(ratio * 100, MidpointRounding.AwayFromZero)); + private string BuildHeaderBarStyle(double offset) + { + var adjustedPercent = Math.Clamp((int)Math.Round((_levelPercent * (0.65d + offset)) + 18d, MidpointRounding.AwayFromZero), 18, 100); + return string.Format(CultureInfo.InvariantCulture, "height:{0}%", adjustedPercent); + } private static int ConvertLevelToDb(int levelPercent) => levelPercent switch @@ -218,7 +235,7 @@ try { - await MicrophoneLevelInterop.StopAsync(UiDomIds.Settings.MicrophoneLevelMonitor); + await MicrophoneLevelInterop.StopAsync(ResolvedMonitorElementId); } catch (Exception exception) { diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsMicrophonesSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsMicrophonesSection.razor new file mode 100644 index 0000000..cb838f3 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsMicrophonesSection.razor @@ -0,0 +1,257 @@ +@namespace PrompterOne.Shared.Components.Settings +@using PrompterOne.Core.Models.Media +@using PrompterOne.Core.Models.Workspace +@using PrompterOne.Shared.Components +@using PrompterOne.Shared.Contracts + +
+

Microphones

+

Choose which microphones are used in GO LIVE. Add audio delay to sync with video if needed.

+ + @if (ShouldShowMediaAccessAction) + { +
+

Device Access

+ +
+ } + +
+ @if (Microphones.Count == 0) + { +
+ No microphones are currently visible to the browser. +
+ } + else + { + @if (SelectedMicrophone is not null) + { + var primaryAudioInput = GetAudioInput(SelectedMicrophone); + var primaryEnabled = IsMicrophoneEnabled(SelectedMicrophone); +
+ + +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+ + 0 - 5000 ms +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ } + + @foreach (var microphone in Microphones.Where(device => !string.Equals(device.DeviceId, SelectedMicrophoneId, StringComparison.Ordinal))) + { + var inputState = GetAudioInput(microphone); + var isEnabled = IsMicrophoneEnabled(microphone); +
+ + +
+
+
+
+
+
+ + @if (isEnabled) + { +
+
+ +
+
+
+ +
+
+
+ +
+ + 0 - 5000 ms +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ } +
+
+
+ } + } +
+ +
+

General

+
+
+ +
+
+
+
+
+
+
+ +@code { + [Parameter] public bool AllMicrophonesMutedOutsideGoLive { get; set; } + + [Parameter] public Func BuildLevelFillStyle { get; set; } = _ => string.Empty; + + [Parameter] public Func BuildLevelThumbStyle { get; set; } = _ => string.Empty; + + [Parameter] public Func BuildMicrophoneMeta { get; set; } = _ => string.Empty; + + [Parameter] public Func BuildToggleCssClass { get; set; } = _ => string.Empty; + + [Parameter] public Func ConvertGainToPercent { get; set; } = _ => 0; + + [Parameter] public string DisplayStyle { get; set; } = string.Empty; + + [Parameter] public Func GetAudioInput { get; set; } = microphone => new AudioInputState(microphone.DeviceId, microphone.Label); + + [Parameter] public bool IsSectionActive { get; set; } + + [Parameter] public Func IsMicrophoneEnabled { get; set; } = _ => false; + + [Parameter] public string MediaAccessActionLabel { get; set; } = string.Empty; + + [Parameter] public bool MicrophoneAccessGranted { get; set; } + + [Parameter] public MicrophoneStudioSettings MicrophoneSettings { get; set; } = new(); + + [Parameter] public IReadOnlyList Microphones { get; set; } = []; + + [Parameter] public EventCallback RequestMediaAccess { get; set; } + + [Parameter] public EventCallback SelectPrimaryMicrophone { get; set; } + + [Parameter] public MediaDeviceInfo? SelectedMicrophone { get; set; } + + [Parameter] public string SelectedMicrophoneId { get; set; } = string.Empty; + + [Parameter] public bool ShouldShowMediaAccessAction { get; set; } + + [Parameter] public EventCallback ToggleEchoCancellation { get; set; } + + [Parameter] public EventCallback ToggleMuteAllMicrophones { get; set; } + + [Parameter] public EventCallback ToggleMicrophoneSelection { get; set; } + + [Parameter] public EventCallback ToggleNoiseSuppression { get; set; } + + [Parameter] public Func? UpdateAudioDelay { get; set; } + + [Parameter] public Func? UpdateAudioGain { get; set; } + + [Parameter] public EventCallback UpdatePrimaryMicLevel { get; set; } + + private Task UpdateAudioDelayAsync(MediaDeviceInfo microphone, ChangeEventArgs args) => + UpdateAudioDelay?.Invoke(microphone, args) ?? Task.CompletedTask; + + private Task UpdateAudioGainAsync(MediaDeviceInfo microphone, ChangeEventArgs args) => + UpdateAudioGain?.Invoke(microphone, args) ?? Task.CompletedTask; +} diff --git a/src/PrompterOne.Shared/Settings/Models/AiProviderSettings.cs b/src/PrompterOne.Shared/Settings/Models/AiProviderSettings.cs new file mode 100644 index 0000000..5bd5a20 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Models/AiProviderSettings.cs @@ -0,0 +1,47 @@ +namespace PrompterOne.Shared.Settings.Models; + +public sealed class AiProviderSettings +{ + public const string StorageKey = "prompterone.ai-providers"; + + public AnthropicAiProviderSettings ClaudeApi { get; set; } = new(); + + public OllamaAiProviderSettings Ollama { get; set; } = new(); + + public OpenAiProviderSettings OpenAi { get; set; } = new(); + + public static AiProviderSettings CreateDefault() => new(); + + public AiProviderSettings Normalize() + { + ClaudeApi ??= new AnthropicAiProviderSettings(); + OpenAi ??= new OpenAiProviderSettings(); + Ollama ??= new OllamaAiProviderSettings(); + return this; + } +} + +public sealed class AnthropicAiProviderSettings +{ + public string ApiKey { get; set; } = string.Empty; + + public string BaseUrl { get; set; } = string.Empty; + + public string Model { get; set; } = "claude-sonnet-4-6"; +} + +public sealed class OpenAiProviderSettings +{ + public string ApiKey { get; set; } = string.Empty; + + public string BaseUrl { get; set; } = string.Empty; + + public string Model { get; set; } = "gpt-4o"; +} + +public sealed class OllamaAiProviderSettings +{ + public string Endpoint { get; set; } = "http://localhost:11434"; + + public string Model { get; set; } = string.Empty; +} diff --git a/src/PrompterOne.Shared/Settings/Models/BrowserFileStorageSettings.cs b/src/PrompterOne.Shared/Settings/Models/BrowserFileStorageSettings.cs new file mode 100644 index 0000000..8d59571 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Models/BrowserFileStorageSettings.cs @@ -0,0 +1,27 @@ +namespace PrompterOne.Shared.Settings.Models; + +public sealed record BrowserFileStorageSettings( + string ExportFormat, + string RecordingsStorageLimit, + bool FileAutoSaveEnabled, + bool FileBackupCopiesEnabled) +{ + public const string StorageKey = "prompterone.file-storage"; + + public static BrowserFileStorageSettings Default { get; } = new( + ExportFormat: "TPS (Native)", + RecordingsStorageLimit: "No limit", + FileAutoSaveEnabled: true, + FileBackupCopiesEnabled: true); +} + +public sealed record FileStorageCardState( + string Subtitle, + string ScopeLabel, + string LocationLabel, + string DetailLabel); + +public sealed record BrowserFileStorageViewState( + FileStorageCardState Scripts, + FileStorageCardState Recordings, + FileStorageCardState Exports); diff --git a/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs b/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs index 96be09c..d3ec139 100644 --- a/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs +++ b/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs @@ -1,18 +1,6 @@ -using PrompterOne.Shared.Pages; - namespace PrompterOne.Shared.Settings.Models; public sealed record SettingsPagePreferences( - string OneDriveSyncFolder, - bool CloudAutoSyncOnSave, - bool CloudSyncOnStartup, - string ScriptsLocation, - string RecordingsLocation, - string ExportsLocation, - string ExportFormat, - string RecordingsStorageLimit, - bool FileAutoSaveEnabled, - bool FileBackupCopiesEnabled, bool AutoRecordWhenStreaming, bool SplitRecordingHourly, string RecordingFolder, @@ -26,7 +14,6 @@ public sealed record SettingsPagePreferences( string RecordingAudioSampleRate, int RecordingAudioBitrateKbps, string RecordingAudioChannels, - string SelectedAiProviderId, string ColorScheme, string AccentColor, string TeleprompterFont, @@ -43,19 +30,9 @@ public sealed record SettingsPagePreferences( public const string StorageKey = "prompterone.settings-page"; public static SettingsPagePreferences Default { get; } = new( - OneDriveSyncFolder: "/OneDrive/Prompter", - CloudAutoSyncOnSave: true, - CloudSyncOnStartup: true, - ScriptsLocation: "/Users/you/Documents/Prompter/Scripts", - RecordingsLocation: "/Users/you/Documents/Prompter/Recordings", - ExportsLocation: "/Users/you/Downloads", - ExportFormat: "TPS (Native)", - RecordingsStorageLimit: "No limit", - FileAutoSaveEnabled: true, - FileBackupCopiesEnabled: true, AutoRecordWhenStreaming: true, SplitRecordingHourly: false, - RecordingFolder: "/Users/you/Documents/Prompter/Recordings", + RecordingFolder: "Browser-local recording store", RecordingNamingPattern: "Script Name + Date", RecordingContainer: "MP4", RecordingVideoCodec: "H.264 (AVC)", @@ -66,7 +43,6 @@ public sealed record SettingsPagePreferences( RecordingAudioSampleRate: "48 kHz", RecordingAudioBitrateKbps: 320, RecordingAudioChannels: "Stereo", - SelectedAiProviderId: SettingsAiProviderIds.ClaudeApi, ColorScheme: SettingsAppearanceValues.DarkColorScheme, AccentColor: SettingsAppearanceValues.DefaultAccentColor, TeleprompterFont: "Inter (Default)", diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Cameras.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Cameras.cs new file mode 100644 index 0000000..9c96b41 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Cameras.cs @@ -0,0 +1,196 @@ +using Microsoft.AspNetCore.Components; +using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.Settings.Components; +using PrompterOne.Shared.Storage; + +namespace PrompterOne.Shared.Pages; + +public partial class SettingsPage +{ + private const string ActiveCardCssClass = "active"; + private const string BuiltInConnectionLabel = "Built-in"; + private const string CameraFeedBaseCssClass = "set-cam-feed"; + private const string DeviceCardCssClass = "set-device-card"; + private const string OffCameraFeedCssClass = "cam-off"; + private const string SelectedCameraCssClass = "set-cam-selected"; + private const string UsbConnectionLabel = "USB"; + private const string VirtualConnectionLabel = "Virtual"; + + private static readonly IReadOnlyList CameraResolutionOptions = + [ + new(nameof(CameraResolutionPreset.FullHd1080), "1920 x 1080 (Full HD)"), + new(nameof(CameraResolutionPreset.Hd720), "1280 x 720 (HD)"), + new(nameof(CameraResolutionPreset.UltraHd4K), "3840 x 2160 (4K)"), + new(nameof(CameraResolutionPreset.Sd480), "640 x 480 (SD)") + ]; + + private async Task ToggleCameraSelectionAsync(MediaDeviceInfo camera) + { + var existing = _sceneCameras.FirstOrDefault(source => string.Equals(source.DeviceId, camera.DeviceId, StringComparison.Ordinal)); + if (existing is null) + { + MediaSceneService.AddCamera(camera.DeviceId, camera.Label); + } + else + { + MediaSceneService.RemoveCamera(existing.SourceId); + } + + await PersistSceneAsync(); + await NormalizeStudioSettingsAsync(); + EnsureCameraPreviewSelection(); + } + + private void SelectCameraPreview(MediaDeviceInfo camera) => _previewCameraId = camera.DeviceId; + + private async Task SetPrimaryCameraAsync(MediaDeviceInfo camera) + { + SelectCameraPreview(camera); + + if (!_sceneCameras.Any(source => string.Equals(source.DeviceId, camera.DeviceId, StringComparison.Ordinal))) + { + MediaSceneService.AddCamera(camera.DeviceId, camera.Label); + await PersistSceneAsync(); + } + + _studioSettings = _studioSettings with + { + Camera = _studioSettings.Camera with + { + DefaultCameraId = camera.DeviceId, + MirrorCamera = ResolveSceneCamera(camera.DeviceId)?.Transform.MirrorHorizontal ?? _studioSettings.Camera.MirrorCamera + } + }; + + await PersistStudioSettingsAsync(); + await NormalizeStudioSettingsAsync(); + } + + private async Task ToggleAllSceneOutputsAsync() + { + if (_sceneCameras.Count == 0) + { + return; + } + + var includeInOutput = !AllSceneCamerasIncludedInOutput; + foreach (var camera in _sceneCameras) + { + MediaSceneService.SetIncludeInOutput(camera.SourceId, includeInOutput); + } + + await PersistSceneAsync(); + await NormalizeStudioSettingsAsync(); + } + + private async Task ToggleSceneMirrorAsync(string sourceId) + { + var source = _sceneCameras.FirstOrDefault(item => string.Equals(item.SourceId, sourceId, StringComparison.Ordinal)); + if (source is null) + { + return; + } + + MediaSceneService.UpdateTransform(sourceId, source.Transform with { MirrorHorizontal = !source.Transform.MirrorHorizontal }); + await PersistSceneAsync(); + await NormalizeStudioSettingsAsync(); + } + + private async Task OnCameraResolutionChanged(ChangeEventArgs args) + { + if (!Enum.TryParse(args.Value?.ToString(), out var resolution)) + { + return; + } + + _studioSettings = _studioSettings with + { + Camera = _studioSettings.Camera with { Resolution = resolution } + }; + + await PersistStudioSettingsAsync(); + } + + private async Task ToggleReaderCameraSceneAsync() + { + var current = SessionService.State.ReaderSettings; + var next = current with { ShowCameraScene = !current.ShowCameraScene }; + await SessionService.UpdateReaderSettingsAsync(next); + await SettingsStore.SaveAsync(BrowserAppSettingsKeys.ReaderSettings, next); + + _studioSettings = _studioSettings with + { + Camera = _studioSettings.Camera with { AutoStartOnRead = next.ShowCameraScene } + }; + + await PersistStudioSettingsAsync(); + } + + private static string BuildCameraCardCssClass(bool isEnabled, bool isPreview) + { + var cssClass = isEnabled ? $"{DeviceCardCssClass} {ActiveCardCssClass}" : DeviceCardCssClass; + return isPreview ? $"{cssClass} {SelectedCameraCssClass}" : cssClass; + } + + private static string BuildCameraFeedCssClass(bool isEnabled) => + isEnabled ? CameraFeedBaseCssClass : $"{CameraFeedBaseCssClass} {OffCameraFeedCssClass}"; + + private string BuildCameraMeta(MediaDeviceInfo camera, bool isPrimary) + { + var parts = new List(capacity: 4) + { + ResolveCameraConnectionLabel(camera), + BuildCameraResolutionSummary(), + BuildFrameRateSummary() + }; + + if (isPrimary) + { + parts.Add("Primary"); + } + + return string.Join(" · ", parts); + } + + private string BuildCameraPreviewDescription() => + PreviewCamera is null ? string.Empty : BuildCameraMeta(PreviewCamera, IsPrimaryCamera(PreviewCamera)); + + private string BuildFrameRateSummary() => + _studioSettings.Camera.FrameRate switch + { + CameraFrameRatePreset.Fps24 => "24fps", + CameraFrameRatePreset.Fps60 => "60fps", + _ => "30fps" + }; + + private string BuildCameraResolutionSummary() => + _studioSettings.Camera.Resolution switch + { + CameraResolutionPreset.Hd720 => "1280×720", + CameraResolutionPreset.UltraHd4K => "3840×2160", + CameraResolutionPreset.Sd480 => "640×480", + _ => "1920×1080" + }; + + private bool IsPrimaryCamera(MediaDeviceInfo camera) => + string.Equals(SelectedCameraId, camera.DeviceId, StringComparison.Ordinal); + + private static string ResolveCameraConnectionLabel(MediaDeviceInfo camera) + { + if (camera.Label.Contains(VirtualConnectionLabel, StringComparison.OrdinalIgnoreCase) + || camera.Label.Contains("OBS", StringComparison.OrdinalIgnoreCase)) + { + return VirtualConnectionLabel; + } + + if (camera.Label.Contains("FaceTime", StringComparison.OrdinalIgnoreCase) + || camera.Label.Contains(BuiltInConnectionLabel, StringComparison.OrdinalIgnoreCase) + || camera.Label.Contains("MacBook", StringComparison.OrdinalIgnoreCase)) + { + return BuiltInConnectionLabel; + } + + return UsbConnectionLabel; + } +} diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.MediaState.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.MediaState.cs new file mode 100644 index 0000000..6886bc6 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.MediaState.cs @@ -0,0 +1,288 @@ +using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Storage; + +namespace PrompterOne.Shared.Pages; + +public partial class SettingsPage +{ + private const string EnableDevicesLabel = "Enable Camera + Mic"; + private const string LoadSettingsMessage = "Unable to load settings right now."; + private const string LoadSettingsOperation = "Settings load"; + private const string PersistSceneMessage = "Unable to save scene changes."; + private const string PersistSceneOperation = "Settings save scene"; + private const string PersistStudioMessage = "Unable to save studio settings."; + private const string PersistStudioOperation = "Settings save studio"; + private const string RefreshDevicesLabel = "Refresh Devices"; + private const string RefreshMediaMessage = "Unable to refresh camera and microphone access."; + private const string RefreshMediaOperation = "Settings media refresh"; + + private bool _loadState = true; + private IReadOnlyList _cameraDevices = []; + private IReadOnlyList _devices = []; + private IReadOnlyList _microphoneDevices = []; + private string? _previewCameraId; + private string? _primaryMicrophoneId; + private MediaPermissionsState _permissions = new(false, false); + private StudioSettings _studioSettings = StudioSettings.Default; + + private IReadOnlyList _sceneCameras => MediaSceneService.State.Cameras; + + private bool AllMicrophonesMutedOutsideGoLive => + _microphoneDevices.Count > 0 && _microphoneDevices.All(microphone => !IsMicrophoneEnabled(microphone)); + + private bool AllSceneCamerasIncludedInOutput => + _sceneCameras.Count > 0 && _sceneCameras.All(camera => camera.Transform.IncludeInOutput); + + private string MediaAccessActionLabel => + _permissions.CameraGranted && _permissions.MicrophoneGranted ? RefreshDevicesLabel : EnableDevicesLabel; + + private MediaDeviceInfo? PreviewCamera => ResolvePreviewCamera(); + + private SceneCameraSource? PreviewSceneCamera => ResolveSceneCamera(PreviewCamera?.DeviceId); + + private string SelectedCameraId => ResolveSelectedCamera()?.DeviceId ?? string.Empty; + + private string SelectedMicrophoneId => ResolveSelectedMicrophone()?.DeviceId ?? string.Empty; + + private bool ShouldShowMediaAccessAction => + !_permissions.CameraGranted || !_permissions.MicrophoneGranted || _devices.Count == 0; + + protected override void OnInitialized() + { + Shell.ShowSettings(); + InitializeCrossTabSync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!_loadState) + { + return; + } + + _loadState = false; + await Diagnostics.RunAsync( + LoadSettingsOperation, + LoadSettingsMessage, + async () => + { + await LoadAsync(); + StateHasChanged(); + }); + } + + private async Task LoadAsync() + { + await Bootstrapper.EnsureReadyAsync(); + await LoadPreferencesAsync(); + _permissions = await MediaPermissionService.QueryAsync(); + + try + { + _devices = await MediaDeviceService.GetDevicesAsync(); + } + catch + { + _devices = []; + } + + _cameraDevices = _devices.Where(device => device.Kind == MediaDeviceKind.Camera).ToList(); + _microphoneDevices = _devices.Where(device => device.Kind == MediaDeviceKind.Microphone).ToList(); + _primaryMicrophoneId = MediaSceneService.State.PrimaryMicrophoneId; + + await SeedSceneDefaultsAsync(); + var loadedSettings = await StudioSettingsStore.LoadAsync(); + _studioSettings = StreamingSettingsNormalizer.Normalize(loadedSettings, _sceneCameras); + if (!EqualityComparer.Default.Equals(loadedSettings, _studioSettings)) + { + await PersistStudioSettingsAsync(); + } + + await NormalizeStudioSettingsAsync(); + EnsureCameraPreviewSelection(); + } + + private void EnsureCameraPreviewSelection() + { + if (!string.IsNullOrWhiteSpace(_previewCameraId) + && _cameraDevices.Any(device => string.Equals(device.DeviceId, _previewCameraId, StringComparison.Ordinal))) + { + return; + } + + _previewCameraId = ResolveSelectedCameraCandidate()?.DeviceId + ?? (_cameraDevices.Count > 0 ? _cameraDevices[0].DeviceId : null); + } + + private async Task SeedSceneDefaultsAsync() + { + var changed = false; + + if (_sceneCameras.Count == 0 && _cameraDevices.Count > 0) + { + var defaultCamera = _cameraDevices.FirstOrDefault(device => device.IsDefault) ?? _cameraDevices[0]; + MediaSceneService.AddCamera(defaultCamera.DeviceId, defaultCamera.Label); + changed = true; + } + + if (string.IsNullOrWhiteSpace(MediaSceneService.State.PrimaryMicrophoneId) && _microphoneDevices.Count > 0) + { + var defaultMicrophone = _microphoneDevices.FirstOrDefault(device => device.IsDefault) ?? _microphoneDevices[0]; + MediaSceneService.SetPrimaryMicrophone(defaultMicrophone.DeviceId, defaultMicrophone.Label); + MediaSceneService.UpsertAudioInput(new AudioInputState(defaultMicrophone.DeviceId, defaultMicrophone.Label)); + _primaryMicrophoneId = defaultMicrophone.DeviceId; + changed = true; + } + + if (changed) + { + await PersistSceneAsync(); + } + } + + private async Task RequestMediaAccessAsync() + { + await Diagnostics.RunAsync( + RefreshMediaOperation, + RefreshMediaMessage, + async () => + { + _permissions = await MediaPermissionService.RequestAsync(); + await LoadAsync(); + }); + } + + private async Task NormalizeStudioSettingsAsync() + { + var selectedCamera = ResolveSelectedCameraCandidate(); + var selectedMicrophone = ResolveSelectedMicrophoneCandidate(); + var selectedSceneCamera = ResolveSceneCamera(selectedCamera?.DeviceId); + var selectedAudioInput = selectedMicrophone is null ? null : GetAudioInput(selectedMicrophone); + var nextSettings = _studioSettings with + { + Camera = _studioSettings.Camera with + { + DefaultCameraId = selectedCamera?.DeviceId, + MirrorCamera = selectedSceneCamera?.Transform.MirrorHorizontal ?? _studioSettings.Camera.MirrorCamera, + AutoStartOnRead = SessionService.State.ReaderSettings.ShowCameraScene + }, + Microphone = _studioSettings.Microphone with + { + DefaultMicrophoneId = selectedMicrophone?.DeviceId, + InputLevelPercent = ClampPercent(selectedAudioInput is null + ? _studioSettings.Microphone.InputLevelPercent + : (int)Math.Round(selectedAudioInput.Gain * 100d)) + }, + Streaming = _studioSettings.Streaming with + { + IncludeCameraInOutput = _sceneCameras.Count == 0 + ? _studioSettings.Streaming.IncludeCameraInOutput + : _sceneCameras.Any(camera => camera.Transform.IncludeInOutput) + } + }; + + var hasChanges = !EqualityComparer.Default.Equals(_studioSettings, nextSettings); + _studioSettings = nextSettings; + if (hasChanges) + { + await PersistStudioSettingsAsync(); + } + } + + private async Task PersistSceneAsync() + { + _primaryMicrophoneId = MediaSceneService.State.PrimaryMicrophoneId; + await Diagnostics.RunAsync( + PersistSceneOperation, + PersistSceneMessage, + () => SettingsStore.SaveAsync(BrowserAppSettingsKeys.SceneSettings, MediaSceneService.State)); + } + + private Task PersistStudioSettingsAsync() => + Diagnostics.RunAsync( + PersistStudioOperation, + PersistStudioMessage, + () => StudioSettingsStore.SaveAsync(_studioSettings)); + + private MediaDeviceInfo? ResolveSelectedCamera() => + _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, ResolveSelectedCameraCandidate()?.DeviceId, StringComparison.Ordinal)) + ?? ResolveSelectedCameraCandidate(); + + private MediaDeviceInfo? ResolveSelectedCameraCandidate() + { + if (!string.IsNullOrWhiteSpace(_studioSettings.Camera.DefaultCameraId)) + { + var configured = _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _studioSettings.Camera.DefaultCameraId, StringComparison.Ordinal)); + if (configured is not null) + { + return configured; + } + } + + var sceneCamera = _sceneCameras.Count > 0 ? _sceneCameras[0] : null; + if (sceneCamera is not null) + { + return _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, sceneCamera.DeviceId, StringComparison.Ordinal)) + ?? new MediaDeviceInfo(sceneCamera.DeviceId, sceneCamera.Label, MediaDeviceKind.Camera); + } + + var fallbackCamera = _cameraDevices.Count > 0 ? _cameraDevices[0] : null; + return _cameraDevices.FirstOrDefault(device => device.IsDefault) ?? fallbackCamera; + } + + private SceneCameraSource? ResolveSceneCamera(string? deviceId) + { + if (!string.IsNullOrWhiteSpace(deviceId)) + { + return _sceneCameras.FirstOrDefault(camera => string.Equals(camera.DeviceId, deviceId, StringComparison.Ordinal)); + } + + return _sceneCameras.Count > 0 ? _sceneCameras[0] : null; + } + + private MediaDeviceInfo? ResolveSelectedMicrophone() => + _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, ResolveSelectedMicrophoneCandidate()?.DeviceId, StringComparison.Ordinal)) + ?? ResolveSelectedMicrophoneCandidate(); + + private MediaDeviceInfo? ResolveSelectedMicrophoneCandidate() + { + if (!string.IsNullOrWhiteSpace(_primaryMicrophoneId)) + { + var current = _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _primaryMicrophoneId, StringComparison.Ordinal)); + if (current is not null) + { + return current; + } + } + + if (!string.IsNullOrWhiteSpace(_studioSettings.Microphone.DefaultMicrophoneId)) + { + var configured = _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _studioSettings.Microphone.DefaultMicrophoneId, StringComparison.Ordinal)); + if (configured is not null) + { + return configured; + } + } + + var fallbackMicrophone = _microphoneDevices.Count > 0 ? _microphoneDevices[0] : null; + return _microphoneDevices.FirstOrDefault(device => device.IsDefault) ?? fallbackMicrophone; + } + + private MediaDeviceInfo? ResolvePreviewCamera() + { + if (!string.IsNullOrWhiteSpace(_previewCameraId)) + { + var previewCamera = _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _previewCameraId, StringComparison.Ordinal)); + if (previewCamera is not null) + { + return previewCamera; + } + } + + return ResolveSelectedCamera(); + } + + private static int ClampPercent(int value) => Math.Clamp(value, 0, 100); +} diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Microphones.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Microphones.cs new file mode 100644 index 0000000..b566435 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Microphones.cs @@ -0,0 +1,245 @@ +using Microsoft.AspNetCore.Components; +using PrompterOne.Core.Models.Media; + +namespace PrompterOne.Shared.Pages; + +public partial class SettingsPage +{ + private const string BluetoothConnectionLabel = "Bluetooth"; + private const string CompactSampleRateLabel = "44.1 kHz"; + private const string MonoChannelLabel = "Mono"; + private const string StereoChannelLabel = "Stereo"; + private const string WideSampleRateLabel = "48 kHz"; + + private async Task SelectPrimaryMicrophoneAsync(MediaDeviceInfo microphone) + { + if (!IsMicrophoneEnabled(microphone)) + { + return; + } + + var current = GetAudioInput(microphone); + MediaSceneService.SetPrimaryMicrophone(microphone.DeviceId, microphone.Label); + MediaSceneService.UpsertAudioInput(current with { IsMuted = false }); + + _primaryMicrophoneId = microphone.DeviceId; + await PersistSceneAsync(); + + _studioSettings = _studioSettings with + { + Microphone = _studioSettings.Microphone with + { + DefaultMicrophoneId = microphone.DeviceId, + InputLevelPercent = ClampPercent((int)Math.Round(current.Gain * 100d)) + } + }; + + await PersistStudioSettingsAsync(); + await NormalizeStudioSettingsAsync(); + } + + private async Task ToggleMicrophoneSelectionAsync(MediaDeviceInfo microphone) + { + var current = GetAudioInput(microphone); + var isEnabled = IsMicrophoneEnabled(microphone); + if (!isEnabled) + { + MediaSceneService.UpsertAudioInput(current with { IsMuted = false }); + MediaSceneService.SetPrimaryMicrophone(microphone.DeviceId, microphone.Label); + _primaryMicrophoneId = microphone.DeviceId; + } + else + { + MediaSceneService.UpsertAudioInput(current with { IsMuted = true }); + + if (string.Equals(_primaryMicrophoneId, microphone.DeviceId, StringComparison.Ordinal)) + { + var nextPrimary = _microphoneDevices + .FirstOrDefault(device => !string.Equals(device.DeviceId, microphone.DeviceId, StringComparison.Ordinal) + && IsMicrophoneEnabled(device)); + MediaSceneService.SetPrimaryMicrophone(nextPrimary?.DeviceId, nextPrimary?.Label); + _primaryMicrophoneId = nextPrimary?.DeviceId; + } + } + + await PersistSceneAsync(); + await NormalizeStudioSettingsAsync(); + } + + private async Task ToggleMuteAllMicrophonesAsync() + { + if (_microphoneDevices.Count == 0) + { + return; + } + + var muteAll = !AllMicrophonesMutedOutsideGoLive; + foreach (var microphone in _microphoneDevices) + { + var current = GetAudioInput(microphone); + MediaSceneService.UpsertAudioInput(current with { IsMuted = muteAll }); + } + + if (muteAll) + { + MediaSceneService.SetPrimaryMicrophone(null); + _primaryMicrophoneId = null; + } + else if (ResolveSelectedMicrophoneCandidate() is { } selectedMicrophone) + { + MediaSceneService.SetPrimaryMicrophone(selectedMicrophone.DeviceId, selectedMicrophone.Label); + _primaryMicrophoneId = selectedMicrophone.DeviceId; + } + + await PersistSceneAsync(); + await NormalizeStudioSettingsAsync(); + } + + private async Task UpdateAudioDelayAsync(MediaDeviceInfo microphone, ChangeEventArgs args) + { + if (!int.TryParse(args.Value?.ToString(), out var delay)) + { + return; + } + + var current = GetAudioInput(microphone); + MediaSceneService.UpsertAudioInput(current with { DelayMs = Math.Clamp(delay, 0, 5000) }); + await PersistSceneAsync(); + } + + private async Task UpdateAudioGainAsync(MediaDeviceInfo microphone, ChangeEventArgs args) + { + if (!double.TryParse(args.Value?.ToString(), out var gainPercent)) + { + return; + } + + var current = GetAudioInput(microphone); + var clampedGainPercent = Math.Clamp(gainPercent, 0d, 200d); + MediaSceneService.UpsertAudioInput(current with { Gain = clampedGainPercent / 100d }); + await PersistSceneAsync(); + + if (string.Equals(_primaryMicrophoneId, microphone.DeviceId, StringComparison.Ordinal)) + { + _studioSettings = _studioSettings with + { + Microphone = _studioSettings.Microphone with + { + InputLevelPercent = ClampPercent((int)Math.Round(clampedGainPercent)) + } + }; + await PersistStudioSettingsAsync(); + } + } + + private AudioInputState GetAudioInput(MediaDeviceInfo microphone) => + MediaSceneService.State.AudioBus.Inputs + .FirstOrDefault(input => string.Equals(input.DeviceId, microphone.DeviceId, StringComparison.Ordinal)) + ?? new AudioInputState(microphone.DeviceId, microphone.Label); + + private async Task UpdatePrimaryMicLevelAsync(ChangeEventArgs args) + { + if (!double.TryParse(args.Value?.ToString(), out var percent)) + { + return; + } + + var clampedPercent = ClampPercent((int)Math.Round(percent)); + _studioSettings = _studioSettings with + { + Microphone = _studioSettings.Microphone with { InputLevelPercent = clampedPercent } + }; + + var primaryMicrophone = ResolveSelectedMicrophone(); + if (primaryMicrophone is not null) + { + var current = GetAudioInput(primaryMicrophone); + MediaSceneService.UpsertAudioInput(current with { Gain = clampedPercent / 100d }); + await PersistSceneAsync(); + } + + await PersistStudioSettingsAsync(); + } + + private async Task ToggleNoiseSuppressionAsync() + { + _studioSettings = _studioSettings with + { + Microphone = _studioSettings.Microphone with + { + NoiseSuppression = !_studioSettings.Microphone.NoiseSuppression + } + }; + + await PersistStudioSettingsAsync(); + } + + private async Task ToggleEchoCancellationAsync() + { + _studioSettings = _studioSettings with + { + Microphone = _studioSettings.Microphone with + { + EchoCancellation = !_studioSettings.Microphone.EchoCancellation + } + }; + + await PersistStudioSettingsAsync(); + } + + private static string BuildLevelFillStyle(int percent) => $"width:{ClampDisplayPercent(percent)}%;"; + + private static string BuildLevelThumbStyle(int percent) => $"left:{ClampDisplayPercent(percent)}%;"; + + private static string BuildMicrophoneMeta(MediaDeviceInfo microphone) => + string.Join( + " · ", + ResolveMicrophoneConnectionLabel(microphone), + ResolveMicrophoneSampleRateLabel(microphone), + ResolveMicrophoneChannelLabel(microphone)); + + private static int ClampDisplayPercent(int percent) => Math.Clamp(percent, 0, 100); + + private static int ConvertGainToPercent(double gain) => + Math.Clamp((int)Math.Round(gain * 100d), 0, 200); + + private bool IsMicrophoneEnabled(MediaDeviceInfo microphone) + { + var inputState = MediaSceneService.State.AudioBus.Inputs + .FirstOrDefault(input => string.Equals(input.DeviceId, microphone.DeviceId, StringComparison.Ordinal)); + if (inputState is not null) + { + return !inputState.IsMuted; + } + + return string.Equals(_primaryMicrophoneId, microphone.DeviceId, StringComparison.Ordinal); + } + + private static string ResolveMicrophoneChannelLabel(MediaDeviceInfo microphone) => + microphone.Label.Contains("Blue Yeti", StringComparison.OrdinalIgnoreCase) + ? StereoChannelLabel + : MonoChannelLabel; + + private static string ResolveMicrophoneConnectionLabel(MediaDeviceInfo microphone) + { + if (microphone.Label.Contains(BluetoothConnectionLabel, StringComparison.OrdinalIgnoreCase) + || microphone.Label.Contains("AirPods", StringComparison.OrdinalIgnoreCase)) + { + return BluetoothConnectionLabel; + } + + if (microphone.Label.Contains(BuiltInConnectionLabel, StringComparison.OrdinalIgnoreCase) + || microphone.Label.Contains("MacBook", StringComparison.OrdinalIgnoreCase)) + { + return BuiltInConnectionLabel; + } + + return UsbConnectionLabel; + } + + private static string ResolveMicrophoneSampleRateLabel(MediaDeviceInfo microphone) => + microphone.Label.Contains(BuiltInConnectionLabel, StringComparison.OrdinalIgnoreCase) + || microphone.Label.Contains("AirPods", StringComparison.OrdinalIgnoreCase) + ? CompactSampleRateLabel + : WideSampleRateLabel; +} diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Navigation.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Navigation.cs index 859dc0a..5843f2d 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Navigation.cs +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Navigation.cs @@ -3,6 +3,7 @@ namespace PrompterOne.Shared.Pages; public partial class SettingsPage { private const string DisplayNoneStyle = "display:none"; + private const string SetNavItemCssClass = "set-nav-item"; private SettingsSection _activeSection = SettingsSection.Cloud; diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs index 90e06d3..d5b85c5 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs @@ -10,6 +10,7 @@ public partial class SettingsPage private const string PersistPreferencesMessage = "Unable to save general settings."; private const string ActiveCssClass = "active"; private const string OnCssClass = "on"; + private const string SetToggleCssClass = "set-toggle"; private readonly HashSet _openCards = new(StringComparer.Ordinal) { @@ -24,10 +25,6 @@ public partial class SettingsPage private SettingsPagePreferences _pagePreferences = SettingsPagePreferences.Default; - private string FileAutoSaveToggleCssClass => BuildToggleCssClass(_pagePreferences.FileAutoSaveEnabled); - - private string FileBackupCopiesToggleCssClass => BuildToggleCssClass(_pagePreferences.FileBackupCopiesEnabled); - [Inject] private BrowserThemeService ThemeService { get; set; } = null!; private bool IsCardOpen(string cardId) => _openCards.Contains(cardId); @@ -51,24 +48,6 @@ private Task PersistPreferencesAsync() => PersistPreferencesMessage, () => SettingsStore.SaveAsync(SettingsPagePreferences.StorageKey, _pagePreferences)); - private async Task SelectAiProviderAsync(string providerId) - { - _pagePreferences = _pagePreferences with { SelectedAiProviderId = providerId }; - await PersistPreferencesAsync(); - } - - private async Task ToggleAutoSaveAsync() - { - _pagePreferences = _pagePreferences with { FileAutoSaveEnabled = !_pagePreferences.FileAutoSaveEnabled }; - await PersistPreferencesAsync(); - } - - private async Task ToggleBackupCopiesAsync() - { - _pagePreferences = _pagePreferences with { FileBackupCopiesEnabled = !_pagePreferences.FileBackupCopiesEnabled }; - await PersistPreferencesAsync(); - } - private async Task TogglePreferenceCardAsync(string cardId) { if (!_openCards.Add(cardId)) @@ -79,18 +58,6 @@ private async Task TogglePreferenceCardAsync(string cardId) await InvokeAsync(StateHasChanged); } - private async Task UpdateExportFormatAsync(string value) - { - _pagePreferences = _pagePreferences with { ExportFormat = value }; - await PersistPreferencesAsync(); - } - - private async Task UpdateRecordingsStorageLimitAsync(string value) - { - _pagePreferences = _pagePreferences with { RecordingsStorageLimit = value }; - await PersistPreferencesAsync(); - } - private async Task ToggleAutoRecordWhenStreamingAsync() { _pagePreferences = _pagePreferences with { AutoRecordWhenStreaming = !_pagePreferences.AutoRecordWhenStreaming }; diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor index 9129478..911214c 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor @@ -76,348 +76,61 @@ - -
-

Cameras

-

Choose which cameras are available in GO LIVE and select the primary stream camera.

- -
-

Device Access

- -
- -
-
- @if (_cameraDevices.Count == 0) - { -
- No camera devices are currently visible to the browser. -
- } - else - { - @foreach (var camera in _cameraDevices) - { - var sceneCamera = ResolveSceneCamera(camera.DeviceId); - var isEnabled = sceneCamera is not null; - var isPrimary = IsPrimaryCamera(camera); - var isPreview = IsPreviewCamera(camera); -
-
-
-
- @if (isPrimary) - { - - - - - - Primary - - } -
-
- @MediaDeviceLabelSanitizer.Sanitize(camera.Label) - @BuildCameraMeta(camera, isPrimary) -
-
-
-
-
-
-
- @if (isEnabled && sceneCamera is not null) - { -
-
-
- - -
-
- -
-
-
-
-
- -
-
-
-
-
- -
- } -
- } - } - -
-

General

-
-
- -
-
-
-
-
-
-
- - -
-
- -
-

Microphones

-

Choose which microphones are used in GO LIVE. Add audio delay to sync with video if needed.

- - @if (ShouldShowMediaAccessAction) - { -
-

Device Access

- -
- } - -
- @if (_microphoneDevices.Count == 0) - { -
- No microphones are currently visible to the browser. -
- } - else - { - var primaryMicrophone = SelectedMicrophone; - @if (primaryMicrophone is not null) - { - var primaryAudioInput = GetAudioInput(primaryMicrophone); - var primaryEnabled = IsMicrophoneEnabled(primaryMicrophone); -
- - -
-
-
-
-
-
- -
-
- -
-
-
- -
-
-
- -
- - 0 - 5000 ms -
-
-
- -
-
-
-
-
- -
-
-
-
-
-
-
-
- } + ToggleCard="TogglePreferenceCardAsync" /> - @foreach (var microphone in _microphoneDevices.Where(device => !string.Equals(device.DeviceId, SelectedMicrophoneId, StringComparison.Ordinal))) - { - var inputState = GetAudioInput(microphone); - var isEnabled = IsMicrophoneEnabled(microphone); -
-
-
- - - - - - -
-
-
-
-
-
-
- @MediaDeviceLabelSanitizer.Sanitize(microphone.Label) - @BuildMicrophoneMeta(microphone) -
-
-
-
-
-
-
- @if (isEnabled) - { -
-
-
-
-
- @BuildMicrophoneDbLabel(inputState.Gain) -
-
-
-
- -
-
-
- -
-
-
- -
- - 0 - 5000 ms -
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- } -
- } - } -
- -
-

General

-
-
- -
-
-
-
-
-
-
+ + +
- -@code { - private const string ActiveCardCssClass = "active"; - private const string BluetoothConnectionLabel = "Bluetooth"; - private const string BuiltInConnectionLabel = "Built-in"; - private const string CameraFeedBaseCssClass = "set-cam-feed"; - private const string CompactSampleRateLabel = "44.1 kHz"; - private const string DeviceCardCssClass = "set-device-card"; - private const string EnableDevicesLabel = "Enable Camera + Mic"; - private const string MonoChannelLabel = "Mono"; - private const string OffCameraFeedCssClass = "cam-off"; - private const string PrimaryCameraButtonActiveCssClass = "set-btn-golden set-btn-sm set-btn-primary-cam active"; - private const string PrimaryCameraButtonCssClass = "set-btn-outline set-btn-sm set-btn-primary-cam"; - private const string PrimaryCameraLabel = "Primary Camera"; - private const string RefreshDevicesLabel = "Refresh Devices"; - private const string SelectedCameraCssClass = "set-cam-selected"; - private const string SetNavItemCssClass = "set-nav-item"; - private const string SetPrimaryCameraLabel = "Set as Primary"; - private const string SetToggleCssClass = "set-toggle"; - private const string StereoChannelLabel = "Stereo"; - private const string UsbConnectionLabel = "USB"; - private const string VirtualConnectionLabel = "Virtual"; - private const string WideSampleRateLabel = "48 kHz"; - - private static readonly IReadOnlyList CameraResolutionOptions = - [ - new(nameof(CameraResolutionPreset.FullHd1080), "1920 x 1080 (Full HD)"), - new(nameof(CameraResolutionPreset.Hd720), "1280 x 720 (HD)"), - new(nameof(CameraResolutionPreset.UltraHd4K), "3840 x 2160 (4K)"), - new(nameof(CameraResolutionPreset.Sd480), "640 x 480 (SD)"), - ]; - - private bool _loadState = true; - private MediaPermissionsState _permissions = new(false, false); - private IReadOnlyList _devices = []; - private IReadOnlyList _cameraDevices = []; - private IReadOnlyList _microphoneDevices = []; - private string? _previewCameraId; - private StudioSettings _studioSettings = StudioSettings.Default; - private string? _primaryMicrophoneId; - private const string LoadSettingsOperation = "Settings load"; - private const string LoadSettingsMessage = "Unable to load settings right now."; - private const string RefreshMediaOperation = "Settings media refresh"; - private const string RefreshMediaMessage = "Unable to refresh camera and microphone access."; - private const string PersistSceneOperation = "Settings save scene"; - private const string PersistSceneMessage = "Unable to save scene changes."; - private const string PersistStudioOperation = "Settings save studio"; - private const string PersistStudioMessage = "Unable to save studio settings."; - - private IReadOnlyList _sceneCameras => MediaSceneService.State.Cameras; - private bool AllMicrophonesMutedOutsideGoLive => _microphoneDevices.Count > 0 && _microphoneDevices.All(microphone => !IsMicrophoneEnabled(microphone)); - private bool AllSceneCamerasIncludedInOutput => _sceneCameras.Count > 0 && _sceneCameras.All(camera => camera.Transform.IncludeInOutput); - private string MediaAccessActionLabel => _permissions.CameraGranted && _permissions.MicrophoneGranted - ? RefreshDevicesLabel - : EnableDevicesLabel; - private MediaDeviceInfo? PreviewCamera => ResolvePreviewCamera(); - private SceneCameraSource? PreviewSceneCamera => ResolveSceneCamera(PreviewCamera?.DeviceId); - private string SelectedCameraId => ResolveSelectedCamera()?.DeviceId ?? string.Empty; - private string SelectedMicrophoneId => ResolveSelectedMicrophone()?.DeviceId ?? string.Empty; - private bool ShouldShowMediaAccessAction => !_permissions.CameraGranted || !_permissions.MicrophoneGranted || _devices.Count == 0; - - protected override void OnInitialized() - { - Shell.ShowSettings(); - InitializeCrossTabSync(); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (!_loadState) - { - return; - } - - _loadState = false; - await Diagnostics.RunAsync( - LoadSettingsOperation, - LoadSettingsMessage, - async () => - { - await LoadAsync(); - StateHasChanged(); - }); - } - - private async Task LoadAsync() - { - await Bootstrapper.EnsureReadyAsync(); - await LoadPreferencesAsync(); - _permissions = await MediaPermissionService.QueryAsync(); - - try - { - _devices = await MediaDeviceService.GetDevicesAsync(); - } - catch - { - _devices = []; - } - - _cameraDevices = _devices.Where(device => device.Kind == MediaDeviceKind.Camera).ToList(); - _microphoneDevices = _devices.Where(device => device.Kind == MediaDeviceKind.Microphone).ToList(); - _primaryMicrophoneId = MediaSceneService.State.PrimaryMicrophoneId; - - await SeedSceneDefaultsAsync(); - var loadedSettings = await StudioSettingsStore.LoadAsync(); - _studioSettings = StreamingSettingsNormalizer.Normalize(loadedSettings, _sceneCameras); - if (!EqualityComparer.Default.Equals(loadedSettings, _studioSettings)) - { - await PersistStudioSettingsAsync(); - } - - await NormalizeStudioSettingsAsync(); - EnsureCameraPreviewSelection(); - } - - private void EnsureCameraPreviewSelection() - { - if (!string.IsNullOrWhiteSpace(_previewCameraId) - && _cameraDevices.Any(device => string.Equals(device.DeviceId, _previewCameraId, StringComparison.Ordinal))) - { - return; - } - - _previewCameraId = ResolveSelectedCameraCandidate()?.DeviceId - ?? _cameraDevices.FirstOrDefault()?.DeviceId; - } - - private async Task SeedSceneDefaultsAsync() - { - var changed = false; - - if (_sceneCameras.Count == 0 && _cameraDevices.Count > 0) - { - var defaultCamera = _cameraDevices.FirstOrDefault(device => device.IsDefault) ?? _cameraDevices.First(); - MediaSceneService.AddCamera(defaultCamera.DeviceId, defaultCamera.Label); - changed = true; - } - - if (string.IsNullOrWhiteSpace(MediaSceneService.State.PrimaryMicrophoneId) && _microphoneDevices.Count > 0) - { - var defaultMicrophone = _microphoneDevices.FirstOrDefault(device => device.IsDefault) ?? _microphoneDevices.First(); - MediaSceneService.SetPrimaryMicrophone(defaultMicrophone.DeviceId, defaultMicrophone.Label); - MediaSceneService.UpsertAudioInput(new AudioInputState(defaultMicrophone.DeviceId, defaultMicrophone.Label)); - _primaryMicrophoneId = defaultMicrophone.DeviceId; - changed = true; - } - - if (changed) - { - await PersistSceneAsync(); - } - } - - private async Task RequestMediaAccessAsync() - { - await Diagnostics.RunAsync( - RefreshMediaOperation, - RefreshMediaMessage, - async () => - { - _permissions = await MediaPermissionService.RequestAsync(); - await LoadAsync(); - }); - } - - private async Task NormalizeStudioSettingsAsync() - { - var selectedCamera = ResolveSelectedCameraCandidate(); - var selectedMicrophone = ResolveSelectedMicrophoneCandidate(); - var selectedSceneCamera = ResolveSceneCamera(selectedCamera?.DeviceId); - var selectedAudioInput = selectedMicrophone is null ? null : GetAudioInput(selectedMicrophone); - var nextSettings = _studioSettings with - { - Camera = _studioSettings.Camera with - { - DefaultCameraId = selectedCamera?.DeviceId, - MirrorCamera = selectedSceneCamera?.Transform.MirrorHorizontal ?? _studioSettings.Camera.MirrorCamera, - AutoStartOnRead = SessionService.State.ReaderSettings.ShowCameraScene - }, - Microphone = _studioSettings.Microphone with - { - DefaultMicrophoneId = selectedMicrophone?.DeviceId, - InputLevelPercent = ClampPercent(selectedAudioInput is null - ? _studioSettings.Microphone.InputLevelPercent - : (int)Math.Round(selectedAudioInput.Gain * 100d)) - }, - Streaming = _studioSettings.Streaming with - { - IncludeCameraInOutput = _sceneCameras.Count == 0 - ? _studioSettings.Streaming.IncludeCameraInOutput - : _sceneCameras.Any(camera => camera.Transform.IncludeInOutput) - } - }; - - var hasChanges = !EqualityComparer.Default.Equals(_studioSettings, nextSettings); - _studioSettings = nextSettings; - if (hasChanges) - { - await PersistStudioSettingsAsync(); - } - } - - private async Task ToggleCameraSelectionAsync(MediaDeviceInfo camera) - { - var existing = _sceneCameras.FirstOrDefault(source => string.Equals(source.DeviceId, camera.DeviceId, StringComparison.Ordinal)); - if (existing is null) - { - MediaSceneService.AddCamera(camera.DeviceId, camera.Label); - } - else - { - MediaSceneService.RemoveCamera(existing.SourceId); - } - - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - EnsureCameraPreviewSelection(); - } - - private void SelectCameraPreview(MediaDeviceInfo camera) - { - _previewCameraId = camera.DeviceId; - } - - private async Task SetPrimaryCameraAsync(MediaDeviceInfo camera) - { - SelectCameraPreview(camera); - - if (!_sceneCameras.Any(source => string.Equals(source.DeviceId, camera.DeviceId, StringComparison.Ordinal))) - { - MediaSceneService.AddCamera(camera.DeviceId, camera.Label); - await PersistSceneAsync(); - } - - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with - { - DefaultCameraId = camera.DeviceId, - MirrorCamera = ResolveSceneCamera(camera.DeviceId)?.Transform.MirrorHorizontal ?? _studioSettings.Camera.MirrorCamera - } - }; - - await PersistStudioSettingsAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleAllSceneOutputsAsync() - { - if (_sceneCameras.Count == 0) - { - return; - } - - var includeInOutput = !AllSceneCamerasIncludedInOutput; - foreach (var camera in _sceneCameras) - { - MediaSceneService.SetIncludeInOutput(camera.SourceId, includeInOutput); - } - - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task RemoveSceneCameraAsync(string sourceId) - { - MediaSceneService.RemoveCamera(sourceId); - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleSceneMirrorAsync(string sourceId) - { - var source = _sceneCameras.FirstOrDefault(item => string.Equals(item.SourceId, sourceId, StringComparison.Ordinal)); - if (source is null) - { - return; - } - - MediaSceneService.UpdateTransform(sourceId, source.Transform with { MirrorHorizontal = !source.Transform.MirrorHorizontal }); - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleSceneFlipAsync(string sourceId) - { - var source = _sceneCameras.FirstOrDefault(item => string.Equals(item.SourceId, sourceId, StringComparison.Ordinal)); - if (source is null) - { - return; - } - - MediaSceneService.UpdateTransform(sourceId, source.Transform with { MirrorVertical = !source.Transform.MirrorVertical }); - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleSceneOutputAsync(string sourceId) - { - var source = _sceneCameras.FirstOrDefault(item => string.Equals(item.SourceId, sourceId, StringComparison.Ordinal)); - if (source is null) - { - return; - } - - MediaSceneService.SetIncludeInOutput(sourceId, !source.Transform.IncludeInOutput); - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task OnDefaultCameraChanged(ChangeEventArgs args) - { - var deviceId = args.Value?.ToString(); - var camera = _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, deviceId, StringComparison.Ordinal)); - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with - { - DefaultCameraId = camera?.DeviceId - } - }; - - if (camera is not null && !_sceneCameras.Any(source => string.Equals(source.DeviceId, camera.DeviceId, StringComparison.Ordinal))) - { - MediaSceneService.AddCamera(camera.DeviceId, camera.Label); - await PersistSceneAsync(); - } - - var selectedSceneCamera = ResolveSceneCamera(camera?.DeviceId); - if (selectedSceneCamera is not null) - { - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with - { - MirrorCamera = selectedSceneCamera.Transform.MirrorHorizontal - } - }; - } - - await PersistStudioSettingsAsync(); - } - - private async Task OnCameraResolutionChanged(ChangeEventArgs args) - { - if (!Enum.TryParse(args.Value?.ToString(), out var resolution)) - { - return; - } - - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with { Resolution = resolution } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task OnCameraFrameRateChanged(ChangeEventArgs args) - { - if (!Enum.TryParse(args.Value?.ToString(), out var frameRate)) - { - return; - } - - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with { FrameRate = frameRate } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task ToggleDefaultCameraMirrorAsync() - { - var nextMirror = !_studioSettings.Camera.MirrorCamera; - var selectedSceneCamera = ResolveSceneCamera(SelectedCameraId); - if (selectedSceneCamera is not null) - { - MediaSceneService.UpdateTransform(selectedSceneCamera.SourceId, selectedSceneCamera.Transform with { MirrorHorizontal = nextMirror }); - await PersistSceneAsync(); - } - - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with { MirrorCamera = nextMirror } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task ToggleReaderCameraSceneAsync() - { - var current = SessionService.State.ReaderSettings; - var next = current with { ShowCameraScene = !current.ShowCameraScene }; - await SessionService.UpdateReaderSettingsAsync(next); - await SettingsStore.SaveAsync(BrowserAppSettingsKeys.ReaderSettings, next); - - _studioSettings = _studioSettings with - { - Camera = _studioSettings.Camera with { AutoStartOnRead = next.ShowCameraScene } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task SelectPrimaryMicrophoneAsync(MediaDeviceInfo microphone) - { - if (!IsMicrophoneEnabled(microphone)) - { - return; - } - - var current = GetAudioInput(microphone); - MediaSceneService.SetPrimaryMicrophone(microphone.DeviceId, microphone.Label); - MediaSceneService.UpsertAudioInput(current with { IsMuted = false }); - - _primaryMicrophoneId = microphone.DeviceId; - await PersistSceneAsync(); - - _studioSettings = _studioSettings with - { - Microphone = _studioSettings.Microphone with - { - DefaultMicrophoneId = microphone.DeviceId, - InputLevelPercent = ClampPercent((int)Math.Round(current.Gain * 100d)) - } - }; - - await PersistStudioSettingsAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleMicrophoneSelectionAsync(MediaDeviceInfo microphone) - { - var current = GetAudioInput(microphone); - var isEnabled = IsMicrophoneEnabled(microphone); - if (!isEnabled) - { - MediaSceneService.UpsertAudioInput(current with { IsMuted = false }); - MediaSceneService.SetPrimaryMicrophone(microphone.DeviceId, microphone.Label); - _primaryMicrophoneId = microphone.DeviceId; - } - else - { - MediaSceneService.UpsertAudioInput(current with { IsMuted = true }); - - if (string.Equals(_primaryMicrophoneId, microphone.DeviceId, StringComparison.Ordinal)) - { - var nextPrimary = _microphoneDevices - .FirstOrDefault(device => !string.Equals(device.DeviceId, microphone.DeviceId, StringComparison.Ordinal) - && IsMicrophoneEnabled(device)); - MediaSceneService.SetPrimaryMicrophone(nextPrimary?.DeviceId, nextPrimary?.Label); - _primaryMicrophoneId = nextPrimary?.DeviceId; - } - } - - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleMuteAllMicrophonesAsync() - { - if (_microphoneDevices.Count == 0) - { - return; - } - - var muteAll = !AllMicrophonesMutedOutsideGoLive; - foreach (var microphone in _microphoneDevices) - { - var current = GetAudioInput(microphone); - MediaSceneService.UpsertAudioInput(current with { IsMuted = muteAll }); - } - - if (muteAll) - { - MediaSceneService.SetPrimaryMicrophone(null); - _primaryMicrophoneId = null; - } - else if (ResolveSelectedMicrophoneCandidate() is { } selectedMicrophone) - { - MediaSceneService.SetPrimaryMicrophone(selectedMicrophone.DeviceId, selectedMicrophone.Label); - _primaryMicrophoneId = selectedMicrophone.DeviceId; - } - - await PersistSceneAsync(); - await NormalizeStudioSettingsAsync(); - } - - private async Task OnPrimaryMicrophoneChanged(ChangeEventArgs args) - { - var deviceId = args.Value?.ToString(); - var microphone = _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, deviceId, StringComparison.Ordinal)); - _primaryMicrophoneId = microphone?.DeviceId; - MediaSceneService.SetPrimaryMicrophone(microphone?.DeviceId, microphone?.Label); - - if (microphone is not null) - { - MediaSceneService.UpsertAudioInput(GetAudioInput(microphone)); - } - - await PersistSceneAsync(); - _studioSettings = _studioSettings with - { - Microphone = _studioSettings.Microphone with { DefaultMicrophoneId = microphone?.DeviceId } - }; - await NormalizeStudioSettingsAsync(); - } - - private async Task ToggleMicMuteAsync(MediaDeviceInfo microphone) - { - var current = GetAudioInput(microphone); - MediaSceneService.UpsertAudioInput(current with { IsMuted = !current.IsMuted }); - await PersistSceneAsync(); - } - - private async Task UpdateAudioDelayAsync(MediaDeviceInfo microphone, ChangeEventArgs args) - { - if (!int.TryParse(args.Value?.ToString(), out var delay)) - { - return; - } - - var current = GetAudioInput(microphone); - MediaSceneService.UpsertAudioInput(current with { DelayMs = Math.Clamp(delay, 0, 5000) }); - await PersistSceneAsync(); - } - - private async Task UpdateAudioGainAsync(MediaDeviceInfo microphone, ChangeEventArgs args) - { - if (!double.TryParse(args.Value?.ToString(), out var gainPercent)) - { - return; - } - - var current = GetAudioInput(microphone); - var clampedGainPercent = Math.Clamp(gainPercent, 0d, 200d); - MediaSceneService.UpsertAudioInput(current with { Gain = clampedGainPercent / 100d }); - await PersistSceneAsync(); - - if (string.Equals(_primaryMicrophoneId, microphone.DeviceId, StringComparison.Ordinal)) - { - _studioSettings = _studioSettings with - { - Microphone = _studioSettings.Microphone with - { - InputLevelPercent = ClampPercent((int)Math.Round(clampedGainPercent)) - } - }; - await PersistStudioSettingsAsync(); - } - } - - private async Task UpdateAudioRouteAsync(MediaDeviceInfo microphone, ChangeEventArgs args) - { - if (!Enum.TryParse(args.Value?.ToString(), out var routeTarget)) - { - return; - } - - var current = GetAudioInput(microphone); - MediaSceneService.UpsertAudioInput(current with { RouteTarget = routeTarget }); - await PersistSceneAsync(); - } - - private AudioInputState GetAudioInput(MediaDeviceInfo microphone) - { - return MediaSceneService.State.AudioBus.Inputs - .FirstOrDefault(input => string.Equals(input.DeviceId, microphone.DeviceId, StringComparison.Ordinal)) - ?? new AudioInputState(microphone.DeviceId, microphone.Label); - } - - private async Task PersistSceneAsync() - { - _primaryMicrophoneId = MediaSceneService.State.PrimaryMicrophoneId; - await Diagnostics.RunAsync( - PersistSceneOperation, - PersistSceneMessage, - () => SettingsStore.SaveAsync(BrowserAppSettingsKeys.SceneSettings, MediaSceneService.State)); - } - - private Task PersistStudioSettingsAsync() => - Diagnostics.RunAsync( - PersistStudioOperation, - PersistStudioMessage, - () => StudioSettingsStore.SaveAsync(_studioSettings)); - - private MediaDeviceInfo? ResolveSelectedCamera() => - _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, ResolveSelectedCameraCandidate()?.DeviceId, StringComparison.Ordinal)) - ?? ResolveSelectedCameraCandidate(); - - private MediaDeviceInfo? ResolveSelectedCameraCandidate() - { - if (!string.IsNullOrWhiteSpace(_studioSettings.Camera.DefaultCameraId)) - { - var configured = _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _studioSettings.Camera.DefaultCameraId, StringComparison.Ordinal)); - if (configured is not null) - { - return configured; - } - } - - var sceneCamera = _sceneCameras.FirstOrDefault(); - if (sceneCamera is not null) - { - return _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, sceneCamera.DeviceId, StringComparison.Ordinal)) - ?? new MediaDeviceInfo(sceneCamera.DeviceId, sceneCamera.Label, MediaDeviceKind.Camera); - } - - return _cameraDevices.FirstOrDefault(device => device.IsDefault) ?? _cameraDevices.FirstOrDefault(); - } - - private SceneCameraSource? ResolveSceneCamera(string? deviceId) - { - if (!string.IsNullOrWhiteSpace(deviceId)) - { - return _sceneCameras.FirstOrDefault(camera => string.Equals(camera.DeviceId, deviceId, StringComparison.Ordinal)); - } - - return _sceneCameras.FirstOrDefault(); - } - - private MediaDeviceInfo? ResolveSelectedMicrophone() => - _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, ResolveSelectedMicrophoneCandidate()?.DeviceId, StringComparison.Ordinal)) - ?? ResolveSelectedMicrophoneCandidate(); - - private MediaDeviceInfo? ResolveSelectedMicrophoneCandidate() - { - if (!string.IsNullOrWhiteSpace(_primaryMicrophoneId)) - { - var current = _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _primaryMicrophoneId, StringComparison.Ordinal)); - if (current is not null) - { - return current; - } - } - - if (!string.IsNullOrWhiteSpace(_studioSettings.Microphone.DefaultMicrophoneId)) - { - var configured = _microphoneDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _studioSettings.Microphone.DefaultMicrophoneId, StringComparison.Ordinal)); - if (configured is not null) - { - return configured; - } - } - - return _microphoneDevices.FirstOrDefault(device => device.IsDefault) ?? _microphoneDevices.FirstOrDefault(); - } - - private async Task UpdatePrimaryMicLevelAsync(ChangeEventArgs args) - { - if (!double.TryParse(args.Value?.ToString(), out var percent)) - { - return; - } - - var clampedPercent = ClampPercent((int)Math.Round(percent)); - _studioSettings = _studioSettings with - { - Microphone = _studioSettings.Microphone with { InputLevelPercent = clampedPercent } - }; - - var primaryMicrophone = ResolveSelectedMicrophone(); - if (primaryMicrophone is not null) - { - var current = GetAudioInput(primaryMicrophone); - MediaSceneService.UpsertAudioInput(current with { Gain = clampedPercent / 100d }); - await PersistSceneAsync(); - } - - await PersistStudioSettingsAsync(); - } - - private async Task ToggleNoiseSuppressionAsync() - { - _studioSettings = _studioSettings with - { - Microphone = _studioSettings.Microphone with - { - NoiseSuppression = !_studioSettings.Microphone.NoiseSuppression - } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task ToggleEchoCancellationAsync() - { - _studioSettings = _studioSettings with - { - Microphone = _studioSettings.Microphone with - { - EchoCancellation = !_studioSettings.Microphone.EchoCancellation - } - }; - - await PersistStudioSettingsAsync(); - } - - private static string BuildCameraCardCssClass(bool isEnabled, bool isPreview) - { - var cssClass = isEnabled - ? $"{DeviceCardCssClass} {ActiveCardCssClass}" - : DeviceCardCssClass; - - return isPreview - ? $"{cssClass} {SelectedCameraCssClass}" - : cssClass; - } - - private static string BuildCameraFeedCssClass(bool isEnabled) => - isEnabled - ? CameraFeedBaseCssClass - : $"{CameraFeedBaseCssClass} {OffCameraFeedCssClass}"; - - private string BuildCameraMeta(MediaDeviceInfo camera, bool isPrimary) - { - var parts = new List(capacity: 4) - { - ResolveCameraConnectionLabel(camera), - BuildCameraResolutionSummary(), - BuildFrameRateSummary() - }; - - if (isPrimary) - { - parts.Add("Primary"); - } - - return string.Join(" · ", parts); - } - - private string BuildCameraPreviewDescription() => - PreviewCamera is null - ? string.Empty - : BuildCameraMeta(PreviewCamera, IsPrimaryCamera(PreviewCamera)); - - private static string BuildLevelFillStyle(int percent) => - $"width:{ClampDisplayPercent(percent)}%;"; - - private static string BuildLevelThumbStyle(int percent) => - $"left:{ClampDisplayPercent(percent)}%;"; - - private static string BuildMicBarStyle(double gain, double offset) - { - var basePercent = ConvertGainToPercent(gain); - var adjustedPercent = Math.Clamp((int)Math.Round(basePercent * (0.65d + offset)), 18, 100); - return $"height:{adjustedPercent}%"; - } - - private static string BuildMicrophoneCardCssClass(bool isEnabled) => - isEnabled - ? $"{DeviceCardCssClass} {ActiveCardCssClass}" - : DeviceCardCssClass; - - private static string BuildMicrophoneDbLabel(double gain) => - string.Concat(ConvertLevelToDb(Math.Min(ConvertGainToPercent(gain), 100)).ToString(), " dB"); - - private string BuildMicrophoneMeta(MediaDeviceInfo microphone) => - string.Join( - " · ", - ResolveMicrophoneConnectionLabel(microphone), - ResolveMicrophoneSampleRateLabel(microphone), - ResolveMicrophoneChannelLabel(microphone)); - - private static int ClampDisplayPercent(int percent) => Math.Clamp(percent, 0, 100); - - private static int ConvertGainToPercent(double gain) => - Math.Clamp((int)Math.Round(gain * 100d), 0, 200); - - private string BuildFrameRateSummary() => - _studioSettings.Camera.FrameRate switch - { - CameraFrameRatePreset.Fps24 => "24fps", - CameraFrameRatePreset.Fps60 => "60fps", - _ => "30fps" - }; - - private string BuildCameraResolutionSummary() => - _studioSettings.Camera.Resolution switch - { - CameraResolutionPreset.Hd720 => "1280×720", - CameraResolutionPreset.UltraHd4K => "3840×2160", - CameraResolutionPreset.Sd480 => "640×480", - _ => "1920×1080" - }; - - private bool IsMicrophoneEnabled(MediaDeviceInfo microphone) - { - var inputState = MediaSceneService.State.AudioBus.Inputs - .FirstOrDefault(input => string.Equals(input.DeviceId, microphone.DeviceId, StringComparison.Ordinal)); - if (inputState is not null) - { - return !inputState.IsMuted; - } - - return string.Equals(_primaryMicrophoneId, microphone.DeviceId, StringComparison.Ordinal); - } - - private bool IsPreviewCamera(MediaDeviceInfo camera) => - string.Equals(PreviewCamera?.DeviceId, camera.DeviceId, StringComparison.Ordinal); - - private bool IsPrimaryCamera(MediaDeviceInfo camera) => - string.Equals(SelectedCameraId, camera.DeviceId, StringComparison.Ordinal); - - private MediaDeviceInfo? ResolvePreviewCamera() - { - if (!string.IsNullOrWhiteSpace(_previewCameraId)) - { - var previewCamera = _cameraDevices.FirstOrDefault(device => string.Equals(device.DeviceId, _previewCameraId, StringComparison.Ordinal)); - if (previewCamera is not null) - { - return previewCamera; - } - } - - return ResolveSelectedCamera(); - } - - private static string ResolveCameraConnectionLabel(MediaDeviceInfo camera) - { - if (camera.Label.Contains(VirtualConnectionLabel, StringComparison.OrdinalIgnoreCase) - || camera.Label.Contains("OBS", StringComparison.OrdinalIgnoreCase)) - { - return VirtualConnectionLabel; - } - - if (camera.Label.Contains("FaceTime", StringComparison.OrdinalIgnoreCase) - || camera.Label.Contains(BuiltInConnectionLabel, StringComparison.OrdinalIgnoreCase) - || camera.Label.Contains("MacBook", StringComparison.OrdinalIgnoreCase)) - { - return BuiltInConnectionLabel; - } - - return UsbConnectionLabel; - } - - private static string ResolveMicrophoneChannelLabel(MediaDeviceInfo microphone) => - microphone.Label.Contains("Blue Yeti", StringComparison.OrdinalIgnoreCase) - ? StereoChannelLabel - : MonoChannelLabel; - - private static string ResolveMicrophoneConnectionLabel(MediaDeviceInfo microphone) - { - if (microphone.Label.Contains(BluetoothConnectionLabel, StringComparison.OrdinalIgnoreCase) - || microphone.Label.Contains("AirPods", StringComparison.OrdinalIgnoreCase)) - { - return BluetoothConnectionLabel; - } - - if (microphone.Label.Contains(BuiltInConnectionLabel, StringComparison.OrdinalIgnoreCase) - || microphone.Label.Contains("MacBook", StringComparison.OrdinalIgnoreCase)) - { - return BuiltInConnectionLabel; - } - - return UsbConnectionLabel; - } - - private static string ResolveMicrophoneSampleRateLabel(MediaDeviceInfo microphone) => - microphone.Label.Contains(BuiltInConnectionLabel, StringComparison.OrdinalIgnoreCase) - || microphone.Label.Contains("AirPods", StringComparison.OrdinalIgnoreCase) - ? CompactSampleRateLabel - : WideSampleRateLabel; - - private static int ClampPercent(int value) => Math.Max(0, Math.Min(100, value)); - - private static int ConvertLevelToDb(int levelPercent) => - levelPercent switch - { - <= 0 => -60, - _ => Math.Clamp((int)Math.Round(-60 + (Math.Min(levelPercent, 100) * 0.48), MidpointRounding.AwayFromZero), -60, -12) - }; -} diff --git a/src/PrompterOne.Shared/Settings/Services/AiProviderSettingsStore.cs b/src/PrompterOne.Shared/Settings/Services/AiProviderSettingsStore.cs new file mode 100644 index 0000000..ead4c98 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Services/AiProviderSettingsStore.cs @@ -0,0 +1,21 @@ +using PrompterOne.Core.Abstractions; +using PrompterOne.Shared.Settings.Models; + +namespace PrompterOne.Shared.Settings.Services; + +public sealed class AiProviderSettingsStore(IUserSettingsStore settingsStore) +{ + private readonly IUserSettingsStore _settingsStore = settingsStore; + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + var settings = await _settingsStore.LoadAsync(AiProviderSettings.StorageKey, cancellationToken); + return (settings ?? AiProviderSettings.CreateDefault()).Normalize(); + } + + public Task SaveAsync(AiProviderSettings settings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + return _settingsStore.SaveAsync(AiProviderSettings.StorageKey, settings.Normalize(), cancellationToken); + } +} diff --git a/src/PrompterOne.Shared/Settings/Services/BrowserFileStorageStore.cs b/src/PrompterOne.Shared/Settings/Services/BrowserFileStorageStore.cs new file mode 100644 index 0000000..0d411cf --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Services/BrowserFileStorageStore.cs @@ -0,0 +1,129 @@ +using ManagedCode.Storage.VirtualFileSystem.Core; +using PrompterOne.Core.Abstractions; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Settings.Models; +using PrompterOne.Shared.Storage; + +namespace PrompterOne.Shared.Settings.Services; + +public sealed class BrowserFileStorageStore( + IUserSettingsStore settingsStore, + IScriptRepository scriptRepository, + ILibraryFolderRepository libraryFolderRepository, + IVirtualFileSystem? virtualFileSystem = null) +{ + private const string BrowserJsonLibraryScopeLabel = "Browser JSON library store"; + private const string EmptyUsageLabel = "No files yet"; + private const string RecordingsScopeLabel = "ManagedCode browser container"; + private const string ScriptsStorageKeyLabel = $"{BrowserStorageKeys.DocumentLibrary} / {BrowserStorageKeys.FolderLibrary}"; + private const string VfsScopeLabel = "ManagedCode.Storage browser VFS"; + + private readonly ILibraryFolderRepository _libraryFolderRepository = libraryFolderRepository; + private readonly IScriptRepository _scriptRepository = scriptRepository; + private readonly IUserSettingsStore _settingsStore = settingsStore; + private readonly IVirtualFileSystem? _virtualFileSystem = virtualFileSystem; + + public async Task LoadSettingsAsync(CancellationToken cancellationToken = default) + { + return await _settingsStore.LoadAsync(BrowserFileStorageSettings.StorageKey, cancellationToken) + ?? BrowserFileStorageSettings.Default; + } + + public async Task LoadViewStateAsync(CancellationToken cancellationToken = default) + { + var scripts = await _scriptRepository.ListAsync(cancellationToken); + var folders = await _libraryFolderRepository.ListAsync(cancellationToken); + var recordingsUsage = await LoadDirectoryUsageAsync(PrompterStorageDefaults.RecordingsDirectoryPath, cancellationToken); + var exportsUsage = await LoadDirectoryUsageAsync(PrompterStorageDefaults.ExportDirectoryPath, cancellationToken); + + return new BrowserFileStorageViewState( + Scripts: new FileStorageCardState( + Subtitle: BuildScriptsSubtitle(scripts.Count, folders.Count), + ScopeLabel: BrowserJsonLibraryScopeLabel, + LocationLabel: ScriptsStorageKeyLabel, + DetailLabel: "Authoritative day-to-day script and folder persistence stays in browser storage, not on a desktop filesystem path."), + Recordings: new FileStorageCardState( + Subtitle: BuildVfsSubtitle(PrompterStorageDefaults.RecordingsDirectoryPath, recordingsUsage), + ScopeLabel: RecordingsScopeLabel, + LocationLabel: BuildDisplayPath(PrompterStorageDefaults.RecordingsDirectoryPath), + DetailLabel: "PrompterOne provisions this browser-local container path for recording artifacts."), + Exports: new FileStorageCardState( + Subtitle: BuildVfsSubtitle(PrompterStorageDefaults.ExportDirectoryPath, exportsUsage), + ScopeLabel: VfsScopeLabel, + LocationLabel: BuildDisplayPath(PrompterStorageDefaults.ExportDirectoryPath), + DetailLabel: "Exports are written to the browser-local container instead of a fake desktop Downloads folder.")); + } + + public Task SaveSettingsAsync(BrowserFileStorageSettings settings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + return _settingsStore.SaveAsync(BrowserFileStorageSettings.StorageKey, settings, cancellationToken); + } + + private async Task LoadDirectoryUsageAsync(string path, CancellationToken cancellationToken) + { + try + { + if (_virtualFileSystem is null) + { + return DirectoryUsage.Empty; + } + + await VfsDirectoryProvisioner.EnsureDirectoryAsync(_virtualFileSystem, path, cancellationToken); + var directory = await _virtualFileSystem.GetDirectoryAsync(path, cancellationToken); + var stats = await directory.GetStatsAsync(true, cancellationToken); + return new DirectoryUsage(stats.FileCount, stats.TotalSize); + } + catch + { + return DirectoryUsage.Empty; + } + } + + private static string BuildDisplayPath(string path) => + string.Concat(PrompterStorageDefaults.BrowserContainerDisplayPrefix, path); + + private static string BuildScriptsSubtitle(int scriptCount, int folderCount) => + string.Concat( + Pluralize(scriptCount, "script"), + " · ", + Pluralize(folderCount, "folder")); + + private static string BuildVfsSubtitle(string path, DirectoryUsage usage) => + string.Concat( + BuildDisplayPath(path), + " · ", + usage.FileCount == 0 ? EmptyUsageLabel : usage.ToDisplayString()); + + private static string Pluralize(int count, string noun) => + count == 1 ? $"1 {noun}" : $"{count} {noun}s"; + + private readonly record struct DirectoryUsage(int FileCount, long TotalSizeBytes) + { + public static DirectoryUsage Empty { get; } = new(0, 0); + + public string ToDisplayString() => + string.Concat( + Pluralize(FileCount, "file"), + " · ", + FormatSize(TotalSizeBytes)); + + private static string FormatSize(long bytes) + { + const double Kilobyte = 1024d; + const double Megabyte = Kilobyte * 1024d; + + if (bytes >= Megabyte) + { + return $"{bytes / Megabyte:0.0} MB"; + } + + if (bytes >= Kilobyte) + { + return $"{bytes / Kilobyte:0.0} KB"; + } + + return $"{bytes} B"; + } + } +} diff --git a/src/PrompterOne.Shared/Storage/Cloud/CloudStorageModels.cs b/src/PrompterOne.Shared/Storage/Cloud/CloudStorageModels.cs index c484c80..062f160 100644 --- a/src/PrompterOne.Shared/Storage/Cloud/CloudStorageModels.cs +++ b/src/PrompterOne.Shared/Storage/Cloud/CloudStorageModels.cs @@ -78,7 +78,7 @@ public sealed class CloudKitStorageProfile public sealed class CloudStoragePreferences { - public bool AutoSyncOnSave { get; set; } = true; + public bool AutoSyncOnSave { get; set; } public CloudKitStorageProfile CloudKit { get; set; } = new(); @@ -92,7 +92,7 @@ public sealed class CloudStoragePreferences public string PrimaryProviderId { get; set; } = CloudStorageProviderIds.OneDrive; - public bool SyncOnStartup { get; set; } = true; + public bool SyncOnStartup { get; set; } public static CloudStoragePreferences CreateDefault() => new(); @@ -151,6 +151,10 @@ public sealed class CloudKitStorageCredentials public sealed class CloudStorageSettingsBundle { + public AiProviderSettings AiProviderSettings { get; set; } = AiProviderSettings.CreateDefault(); + + public BrowserFileStorageSettings FileStorageSettings { get; set; } = BrowserFileStorageSettings.Default; + public LearnSettings LearnSettings { get; set; } = new(); public ReaderSettings ReaderSettings { get; set; } = new(); diff --git a/src/PrompterOne.Shared/Storage/Cloud/CloudStorageTransferService.cs b/src/PrompterOne.Shared/Storage/Cloud/CloudStorageTransferService.cs index f6fd9bd..f41c221 100644 --- a/src/PrompterOne.Shared/Storage/Cloud/CloudStorageTransferService.cs +++ b/src/PrompterOne.Shared/Storage/Cloud/CloudStorageTransferService.cs @@ -8,6 +8,7 @@ using PrompterOne.Core.Models.Workspace; using PrompterOne.Shared.Services; using PrompterOne.Shared.Settings.Models; +using PrompterOne.Shared.Settings.Services; namespace PrompterOne.Shared.Storage.Cloud; @@ -19,8 +20,12 @@ public sealed class CloudStorageTransferService( IScriptRepository scriptRepository, IScriptSessionService scriptSessionService, IMediaSceneService mediaSceneService, - StudioSettingsStore studioSettingsStore) + StudioSettingsStore studioSettingsStore, + AiProviderSettingsStore aiProviderSettingsStore, + BrowserFileStorageStore browserFileStorageStore) { + private readonly AiProviderSettingsStore _aiProviderSettingsStore = aiProviderSettingsStore; + private readonly BrowserFileStorageStore _browserFileStorageStore = browserFileStorageStore; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -142,6 +147,8 @@ private async Task BuildSettingsBundleAsync(Cancella { return new CloudStorageSettingsBundle { + AiProviderSettings = await _aiProviderSettingsStore.LoadAsync(cancellationToken), + FileStorageSettings = await _browserFileStorageStore.LoadSettingsAsync(cancellationToken), LearnSettings = await _settingsStore.LoadAsync(BrowserAppSettingsKeys.LearnSettings, cancellationToken) ?? new LearnSettings(), ReaderSettings = await _settingsStore.LoadAsync(BrowserAppSettingsKeys.ReaderSettings, cancellationToken) ?? new ReaderSettings(), SceneState = await _settingsStore.LoadAsync(BrowserAppSettingsKeys.SceneSettings, cancellationToken) ?? MediaSceneState.Empty, @@ -230,6 +237,8 @@ private async Task RestoreSettingsAsync( { var bundle = settings ?? new CloudStorageSettingsBundle(); + await _aiProviderSettingsStore.SaveAsync(bundle.AiProviderSettings, cancellationToken); + await _browserFileStorageStore.SaveSettingsAsync(bundle.FileStorageSettings, cancellationToken); await _settingsStore.SaveAsync(SettingsPagePreferences.StorageKey, bundle.SettingsPagePreferences, cancellationToken); await _settingsStore.SaveAsync(BrowserAppSettingsKeys.ReaderSettings, bundle.ReaderSettings, cancellationToken); await _settingsStore.SaveAsync(BrowserAppSettingsKeys.LearnSettings, bundle.LearnSettings, cancellationToken); diff --git a/src/PrompterOne.Shared/Storage/PrompterStorageDefaults.cs b/src/PrompterOne.Shared/Storage/PrompterStorageDefaults.cs index c5e3bce..95bedc5 100644 --- a/src/PrompterOne.Shared/Storage/PrompterStorageDefaults.cs +++ b/src/PrompterOne.Shared/Storage/PrompterStorageDefaults.cs @@ -4,12 +4,15 @@ public static class PrompterStorageDefaults { public const int BrowserChunkBatchSize = 4; public const int BrowserChunkSizeBytes = 4 * 1024 * 1024; + public const string BrowserContainerDisplayPrefix = LocalBrowserContainerName + ":"; public const string LocalBrowserContainerName = "prompterone-local"; public const string LocalBrowserDatabaseName = "prompterone-storage"; + public const string ExportDirectoryPath = "/exports"; public const string LibraryRootPath = "/library"; - public const string ScriptDirectoryPath = LibraryRootPath + "/scripts"; public const string FolderDirectoryPath = LibraryRootPath + "/folders"; + public const string RecordingsDirectoryPath = "/recordings"; + public const string ScriptDirectoryPath = LibraryRootPath + "/scripts"; public const string SettingsRootPath = "/settings"; public const string SettingsSnapshotFilePath = SettingsRootPath + "/browser-settings.json"; } diff --git a/src/PrompterOne.Shared/wwwroot/design/modules/settings/20-reference.css b/src/PrompterOne.Shared/wwwroot/design/modules/settings/20-reference.css index b4525bf..abb5f0c 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/settings/20-reference.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/settings/20-reference.css @@ -59,14 +59,15 @@ .set-dest-card { background: rgba(6, 8, 16, .4); - border: 1px solid var(--gold-08); + border: 1px solid var(--gold-10); border-radius: var(--r-lg); cursor: pointer; - transition: border-color .15s; + transition: border-color .15s, box-shadow .15s, transform .15s; } .set-dest-card:hover { - border-color: var(--gold-14); + border-color: var(--gold-16); + box-shadow: 0 0 18px rgba(196, 160, 96, .05); } .set-dest-card.open { @@ -97,6 +98,8 @@ align-items: center; justify-content: center; flex-shrink: 0; + border: 1px solid rgba(255, 255, 255, .05); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .02); } .set-dest-yt { @@ -145,6 +148,14 @@ letter-spacing: .5px; } +.set-dest-idle { + color: rgba(245, 230, 208, .76); +} + +.set-dest-local { + color: #A7F3D0; +} + .set-dest-ok { color: #6EE7B7; } @@ -287,10 +298,10 @@ background: rgba(6, 8, 16, .45); backdrop-filter: blur(24px) saturate(1.2); -webkit-backdrop-filter: blur(24px) saturate(1.2); - border: 1px solid var(--gold-06); + border: 1px solid var(--gold-08); border-radius: var(--r-lg); overflow: hidden; - opacity: .75; + opacity: .9; transition: opacity .2s, border-color .2s, box-shadow .2s; } @@ -309,6 +320,11 @@ box-shadow: 0 0 24px var(--gold-08); } +.set-device-card:not(.active) { + border-color: rgba(196, 160, 96, .10); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .015); +} + .set-device-header { display: flex; align-items: center; @@ -339,15 +355,15 @@ display: flex; align-items: center; justify-content: center; - background: rgba(10, 14, 20, .6); + background: linear-gradient(180deg, rgba(18, 24, 34, .88) 0%, rgba(10, 14, 20, .74) 100%); } .set-cam-feed.cam-off::after { content: ""; width: 20px; height: 20px; - opacity: .5; - background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none' stroke='%234a4a4a' stroke-width='1.5'%3E%3Cpath d='M1 1l18 18M15 3H5a2 2 0 00-2 2v10'/%3E%3C/svg%3E") center/contain no-repeat; + opacity: .85; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' fill='none' stroke='%23c4a060' stroke-width='1.5'%3E%3Cpath d='M1 1l18 18M15 3H5a2 2 0 00-2 2v10'/%3E%3C/svg%3E") center/contain no-repeat; } .set-device-badge { @@ -385,9 +401,9 @@ } .set-mic-preview.set-mic-off { - opacity: .55; - color: var(--t4); - background: rgba(10, 14, 20, .3); + opacity: .82; + color: rgba(232, 213, 176, .76); + background: linear-gradient(180deg, rgba(24, 28, 40, .72) 0%, rgba(10, 14, 20, .42) 100%); } .set-mic-level-indicator { @@ -440,7 +456,7 @@ } .set-device-card:not(.active) .set-device-name { - color: var(--t3); + color: rgba(245, 230, 208, .82); } .set-device-meta { @@ -449,6 +465,10 @@ font-size: 11px; } +.set-device-card:not(.active) .set-device-meta { + color: rgba(199, 182, 153, .74); +} + .set-device-actions { flex-shrink: 0; } diff --git a/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs b/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs index 671f329..ef65863 100644 --- a/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs +++ b/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs @@ -5,6 +5,7 @@ using PrompterOne.Shared.Pages; using PrompterOne.Shared.Services; using PrompterOne.Shared.Settings.Models; +using PrompterOne.Shared.Storage; using PrompterOne.Shared.Storage.Cloud; using PrompterOne.Shared.Tests; @@ -54,7 +55,6 @@ public void CloudSection_SaveAndTest_PersistsDropboxPreferences_AndShowsValidati cut.FindByTestId(UiTestIds.Settings.CloudProviderField(CloudStorageProviderIds.Dropbox, CloudStorageFieldIds.AccountLabel)) .Change(DropboxLabel); cut.SelectSettingsOption(UiTestIds.Settings.CloudDefaultProvider, CloudStorageProviderIds.Dropbox); - cut.FindByTestId(UiTestIds.Settings.CloudAutoSyncOnSave).Click(); cut.FindByTestId(UiTestIds.Settings.CloudProviderConnect(CloudStorageProviderIds.Dropbox)).Click(); var preferences = _harness.JsRuntime.GetSavedValue(CloudStorageStoreKeys.Preferences); @@ -181,6 +181,7 @@ public void ExactStudioControls_PersistCameraMicAndStreamingPreferences() cut.WaitForAssertion(() => Assert.Contains(UiTestIds.Settings.CameraResolution, cut.Markup, StringComparison.Ordinal)); + var initialMirrorState = _harness.JsRuntime.GetSavedValue(StudioSettingsStore.StorageKey).Camera.MirrorCamera; cut.SelectSettingsOption(UiTestIds.Settings.CameraResolution, CameraResolutionPreset.Hd720.ToString()); cut.FindByTestId(UiTestIds.Settings.CameraMirrorToggle).Click(); cut.FindByTestId(UiTestIds.Settings.MicLevel).Input(82); @@ -188,11 +189,45 @@ public void ExactStudioControls_PersistCameraMicAndStreamingPreferences() var settings = _harness.JsRuntime.GetSavedValue(StudioSettingsStore.StorageKey); Assert.Equal(CameraResolutionPreset.Hd720, settings.Camera.Resolution); - Assert.False(settings.Camera.MirrorCamera); + Assert.Equal(!initialMirrorState, settings.Camera.MirrorCamera); Assert.Equal(82, settings.Microphone.InputLevelPercent); Assert.False(settings.Microphone.NoiseSuppression); } + [Fact] + public void FileStorageSection_RendersBrowserLocalStorageLabels_InsteadOfDesktopPaths() + { + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains(UiTestIds.Settings.FilesPanel, cut.Markup, StringComparison.Ordinal)); + + var markup = cut.Markup; + Assert.DoesNotContain("/Users/you/", markup, StringComparison.Ordinal); + Assert.Contains(BrowserStorageKeys.DocumentLibrary, markup, StringComparison.Ordinal); + Assert.Contains(PrompterStorageDefaults.BrowserContainerDisplayPrefix, markup, StringComparison.Ordinal); + } + + [Fact] + public void AiSection_SaveOpenAiDraft_PersistsLocalConfiguration_AndShowsLocalOnlyMessage() + { + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains(UiTestIds.Settings.AiPanel, cut.Markup, StringComparison.Ordinal)); + + cut.FindByTestId(UiTestIds.Settings.NavAi).Click(); + cut.FindByTestId(UiTestIds.Settings.AiProvider(SettingsAiProviderIds.OpenAi)).Click(); + cut.FindAll("input") + .First(input => string.Equals(input.GetAttribute("placeholder"), "sk-...", StringComparison.Ordinal)) + .Change("sk-live-openai"); + cut.FindByTestId(UiTestIds.Settings.AiProviderSave(SettingsAiProviderIds.OpenAi)).Click(); + + var savedSettings = _harness.JsRuntime.GetSavedValue(AiProviderSettings.StorageKey); + Assert.Equal("sk-live-openai", savedSettings.OpenAi.ApiKey); + Assert.Equal( + "Saved locally in this browser. Runtime connection testing is not available yet.", + cut.FindByTestId(UiTestIds.Settings.AiProviderMessage(SettingsAiProviderIds.OpenAi)).TextContent.Trim()); + } + [Fact] public void StreamingPanel_RendersOnlyPersistedExternalDestinations() { diff --git a/tests/PrompterOne.App.Tests/Support/TestSupport.cs b/tests/PrompterOne.App.Tests/Support/TestSupport.cs index 8e5c484..56aa691 100644 --- a/tests/PrompterOne.App.Tests/Support/TestSupport.cs +++ b/tests/PrompterOne.App.Tests/Support/TestSupport.cs @@ -107,7 +107,9 @@ public static AppHarness Create( context.Services.AddSingleton(settingsStore); context.Services.AddSingleton(settingsStore); context.Services.AddSingleton(settingsStore); + context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); diff --git a/tests/PrompterOne.App.UITests/Settings/SettingsCloudStorageFlowTests.cs b/tests/PrompterOne.App.UITests/Settings/SettingsCloudStorageFlowTests.cs index febdc15..a9860a2 100644 --- a/tests/PrompterOne.App.UITests/Settings/SettingsCloudStorageFlowTests.cs +++ b/tests/PrompterOne.App.UITests/Settings/SettingsCloudStorageFlowTests.cs @@ -20,12 +20,14 @@ await Expect(page.GetByTestId(UiTestIds.Settings.Page)).ToBeVisibleAsync( new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); await Expect(page.GetByTestId(UiTestIds.Settings.CloudPanel)).ToBeVisibleAsync( new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + await EnsureToggleOffAsync(page.GetByTestId(UiTestIds.Settings.CloudAutoSyncOnSave)); await SettingsSelectDriver.SelectByValueAsync( page, UiTestIds.Settings.CloudDefaultProvider, CloudStorageProviderIds.Dropbox); - await ToggleSettingsButtonAsync(page.GetByTestId(UiTestIds.Settings.CloudAutoSyncOnSave)); + await Expect(page.GetByTestId(UiTestIds.Settings.CloudAutoSyncOnSave)) + .Not.ToHaveClassAsync(BrowserTestConstants.Regexes.ToggleOnClass); var accountLabelField = page.GetByTestId( UiTestIds.Settings.CloudProviderField(CloudStorageProviderIds.Dropbox, CloudStorageFieldIds.AccountLabel)); await Expect(accountLabelField).ToBeVisibleAsync(); @@ -58,23 +60,19 @@ await UiScenarioArtifacts.CapturePageAsync( BrowserTestConstants.SettingsFlow.CloudStorageReloadedStep); }); - private static async Task ToggleSettingsButtonAsync(ILocator locator) + private static async Task EnsureToggleOffAsync(ILocator locator) { - var wasOn = await HasOnClassAsync(locator); - await locator.ClickAsync(); - - if (wasOn) + if (await HasOnClassAsync(locator)) { + await locator.ClickAsync(); await Expect(locator).Not.ToHaveClassAsync(BrowserTestConstants.Regexes.ToggleOnClass); } - else - { - await Expect(locator).ToHaveClassAsync(BrowserTestConstants.Regexes.ToggleOnClass); - } } - private static async Task HasOnClassAsync(ILocator locator) => - (await locator.GetAttributeAsync(BrowserTestConstants.Html.ClassAttribute) ?? string.Empty) - .Split(BrowserTestConstants.Html.ClassSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Contains(BrowserTestConstants.Css.OnClass, StringComparer.Ordinal); + private static async Task HasOnClassAsync(ILocator locator) + { + var classes = await locator.GetAttributeAsync("class"); + return (classes ?? string.Empty).Contains("on", StringComparison.Ordinal); + } + } diff --git a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs index cc6e5ae..780271a 100644 --- a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs +++ b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs @@ -221,7 +221,9 @@ public static class GoLive { public const string AutoSeedScenario = "go-live-auto-seed"; public const string AutoSeedStudioStep = "01-default-studio-shell"; + public const string ActiveStateValue = "active"; public const string IdleDotColorChannel = "239, 68, 68"; + public const string LiveStateAttributeName = "data-live-state"; public const string ScreenTitle = "Go Live"; public const string CameraSwitchScenario = "go-live-camera-switch"; public const string CameraSwitchStep = "01-secondary-on-air"; @@ -237,16 +239,24 @@ public static class GoLive public const string LiveKitRoom = "launch-room"; public const string LiveKitServer = "wss://livekit.example.com"; public const string LiveKitToken = "lk-test-token"; + public const string LiveLevelAttributeName = "data-live-level"; public const string MicChannelId = "mic"; + public const string Mp4ContainerLabel = "MP4"; + public const string Mp4MimeFragment = "mp4"; + public const string OutputWidthLabel = "1920"; public const string PrimaryParticipantId = "host"; public const string PrompterUtilitySourceId = "prompter-display"; + public const string ProgramChannelId = "program"; public const string RecordingStateValue = "recording"; + public const string RecordingChannelId = "recording"; public const int SharedContextPageCount = 2; public const string RuntimeSessionId = "go-live-program"; public const string SceneStorageKey = "prompterone.settings.prompterone.scene"; public const string SecondSourceId = "scene-cam-b"; public const string SideCameraLabel = "Side camera"; public const string StreamingStateValue = "streaming"; + public const string ByteSuffix = "B"; + public const string WebmContainerLabel = "WEBM"; public const string WidgetReturnScreenshotPath = "output/playwright/go-live-widget-return.png"; public const string InstallLiveKitHarnessScript = """ () => { @@ -305,8 +315,14 @@ async connect(url, token) { public const string GetRuntimeStateScript = "sessionId => window.PrompterOneGoLiveOutput.getSessionState(sessionId)"; public const string RecordingRuntimeActiveScript = "sessionId => Boolean(window.PrompterOneGoLiveOutput.getSessionState(sessionId)?.recording?.active)"; + public const string RecordingRuntimeMetadataReadyScript = + "sessionId => { const state = window.PrompterOneGoLiveOutput.getSessionState(sessionId); return Boolean(state?.recording?.active && state?.recording?.fileName && state?.recording?.mimeType && (state?.recording?.sizeBytes ?? 0) > 0); }"; public const string RecordingRuntimeInactiveScript = "sessionId => !Boolean(window.PrompterOneGoLiveOutput.getSessionState(sessionId)?.recording?.active)"; + public const string RecordingRuntimeUsesProgramSourceScript = + "([sessionId, sourceId]) => window.PrompterOneGoLiveOutput.getSessionState(sessionId)?.program?.primarySourceId === sourceId"; + public static string RecordingRuntimeAudioLevelsReadyScript { get; } = + "([sessionId, minimumLevel]) => { const state = window.PrompterOneGoLiveOutput.getSessionState(sessionId); return (state?.audio?.programLevelPercent ?? 0) >= minimumLevel && (state?.audio?.recordingLevelPercent ?? 0) >= minimumLevel; }"; public const string ObsRuntimeAudioAttachedScript = "sessionId => Boolean(window.PrompterOneGoLiveOutput.getSessionState(sessionId)?.obs?.audioAttached)"; public const string ResolveCameraDeviceScript = """ @@ -574,6 +590,7 @@ public static class Regexes public static Regex SettingsAboutVersion { get; } = new(@"^Version 0\.1\.\d+ · Build \d+$", RegexOptions.Compiled); public static Regex ToggleOnClass { get; } = new(@"\bon\b", RegexOptions.Compiled); public static Regex NonZeroWidth { get; } = new(@"width:\s*0%", RegexOptions.Compiled); + public static Regex ReaderFirstBlockIndicator { get; } = new(@"^1 / \d+$", RegexOptions.Compiled); public static Regex ReaderTimeNotZero { get; } = new(@"^0:00 /", RegexOptions.Compiled); public static Regex ReaderSecondBlockIndicator { get; } = new(@"^2 / \d+$", RegexOptions.Compiled); public static Regex CameraAutoStart { get; } = new(@"true|false", RegexOptions.Compiled); diff --git a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs index 0f0d4a6..b2056ce 100644 --- a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs +++ b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs @@ -169,8 +169,8 @@ private static async Task VerifyAiAndInfoSettingsAsync(Microsoft.Playwright.IPag var openAiProvider = page.GetByTestId(UiTestIds.Settings.AiProvider(BrowserTestConstants.SettingsFlow.OpenAiProviderId)); await openAiProvider.ClickAsync(); - await Expect(openAiProvider).ToHaveClassAsync(BrowserTestConstants.Regexes.ActiveClass); - await Expect(page.GetByTestId(UiTestIds.Settings.TestConnection)).ToBeVisibleAsync(); + await Expect(openAiProvider).ToHaveClassAsync(new Regex(@"\bopen\b")); + await Expect(page.GetByTestId(UiTestIds.Settings.AiProviderSave(BrowserTestConstants.SettingsFlow.OpenAiProviderId))).ToBeVisibleAsync(); await page.GetByTestId(UiTestIds.Settings.NavAppearance).ClickAsync(); await Expect(page.GetByTestId(UiTestIds.Settings.AppearancePanel)).ToBeVisibleAsync();