diff --git a/AGENTS.md b/AGENTS.md index e781699..73eb511 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,8 +352,9 @@ Repo-specific design rules: - CI or deployment work is not done when GitHub Actions is merely green; the deployed GitHub Pages app MUST be opened and verified to boot without shell errors. - GitHub Pages deployment for `prompter.managed-code.com` MUST serve from the custom-domain root with ``; repo-name path prefixes are forbidden. - Version text shown in the app must come from automated build or release metadata, never from manually edited About copy. -- The runtime must negotiate browser language from supported cultures and default to English. -- Supported runtime cultures are English, Ukrainian, French, Spanish, Portuguese, and Italian. +- The runtime must negotiate the initial language from the browser's supported cultures, fall back to English when there is no supported match, and persist the user's explicit language choice in user settings for later sessions. +- UI localization in Blazor must use resource-based localization with shared catalogs and named culture constants instead of screen-local hardcoded strings. +- Supported runtime cultures are English, Ukrainian, French, Spanish, Portuguese, Italian, and German. - Russian must never be added as a supported runtime culture. ### Critical diff --git a/docs/Architecture.md b/docs/Architecture.md index dfd1511..e25fecd 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -100,7 +100,7 @@ flowchart LR | `Storage` | Browser persistence and cloud transfer orchestration | Keeps scripts and settings local-first while exposing provider-backed import/export | `src/PrompterOne.Shared/Storage`, `src/PrompterOne.Shared/Library/Services/Storage` | browser `IStorage` and VFS registration, authoritative browser repositories for scripts/folders, provider credential persistence, scripts/settings snapshot transfer | routed page layout, teleprompter rendering, video-stream upload workflows | | `Cross-Tab Messaging` | Same-origin browser runtime coordination | Lets separate WASM tabs coordinate browser-owned state without a backend | `src/PrompterOne.Shared/AppShell/Services`, `src/PrompterOne.Shared/Settings/Services`, `src/PrompterOne.Shared/wwwroot/app` | `BroadcastChannel` bridge, typed envelopes, settings fan-out, same-origin tab sync | server state, cross-origin transport, collaborative editor conflict resolution | | `Diagnostics` | Error and operation feedback layer | Makes recoverable and fatal issues visible in the shell | `src/PrompterOne.Shared/Diagnostics` | banners, error boundary reporting, operation status wiring | owning business logic of the failing feature | -| `Localization` | Culture and UI text contract | Keeps supported runtime languages consistent and browser-driven | `src/PrompterOne.Shared/Localization`, `src/PrompterOne.Core/Localization` | text catalogs, culture bootstrap, supported culture rules | feature behavior or screen-specific layout ownership | +| `Localization` | Culture and UI text contract | Keeps supported runtime languages consistent, browser-driven, and user-overridable through persisted settings | `src/PrompterOne.Shared/Localization`, `src/PrompterOne.Core/Localization` | shared resource catalogs, startup culture bootstrap, browser-language negotiation, persisted user language override, supported culture rules | feature behavior or screen-specific layout ownership | | `Workspace` | Active script/session state model | Gives editor, learn, read, and go-live one shared script context | `src/PrompterOne.Core/Workspace` | loaded script state, previews, estimated duration, active session metadata | feature-specific rendering details | | `Media` | Browser media and scene domain | Models cameras, microphones, transforms, and audio bus state | `src/PrompterOne.Core/Media`, `src/PrompterOne.Shared/Media` | media device models, scene state, browser media interop | routed screen layout ownership | | `Streaming` | Program capture, transport, and target routing domain | Defines how one composed program feed is described, which source/output modules can attach to it, and which external targets are genuinely reachable without a PrompterOne backend | `src/PrompterOne.Core/Streaming` | program-capture profiles, source/output module contracts, transport connection profiles, downstream target descriptors, routing normalization, standalone transport constraints | Razor UI or page layout concerns | @@ -397,6 +397,7 @@ If a native embedded browser host returns later, media access must not rely on s - standalone Blazor WebAssembly host - serves the app shell and static asset references - applies browser-language culture selection before the WASM runtime starts rendering routed UI +- falls back to the persisted user language override from typed settings before browser negotiation, with English as the final fallback - must stay free of server-only runtime dependencies ### `src/PrompterOne.Shared` @@ -407,7 +408,7 @@ If a native embedded browser host returns later, media access must not rely on s - exact design shell and imported `design` assets - shared UI localization catalog for supported browser cultures - browser interop and app DI wiring -- browser-backed `IUserSettingsStore` wiring for persisted reader, theme, scene, and studio preferences +- browser-backed `IUserSettingsStore` wiring for persisted reader, theme, language, scene, and studio preferences - dynamic library folder components and folder/document browser storage adapters - UI diagnostics banner and global error boundary - debounced editor autosave and body-only TPS source authoring @@ -547,7 +548,7 @@ flowchart LR - The runtime must remain backend-free. - `Go Live` may operate multiple concurrent browser publish transports when the operator explicitly arms them, but every active transport must still consume the same canonical browser-owned program feed. - `Go Live` must not require any PrompterOne-owned backend, relay, ingest layer, or media server; only third-party browser-facing transport infrastructure is allowed. -- Browser-language localization must default to English and support `en`, `uk`, `fr`, `es`, `pt`, and `it`. +- Browser-language localization must default to English, must allow a persisted user override, and must support `en`, `uk`, `fr`, `es`, `it`, `de`, and `pt`. - Russian is intentionally unsupported and must fall back to English. - Visual fidelity should prefer copying the exact design classes and structure over inventing replacements. - Browser tests require Playwright Chromium to be installed locally. diff --git a/src/PrompterOne.App/Program.cs b/src/PrompterOne.App/Program.cs index 3232c76..ce61fa6 100644 --- a/src/PrompterOne.App/Program.cs +++ b/src/PrompterOne.App/Program.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.JSInterop; using PrompterOne.App; using PrompterOne.App.Services; +using PrompterOne.Shared.Localization; using PrompterOne.Shared.Services; using PrompterOne.Shared.Settings.Services; @@ -22,5 +22,5 @@ builder.Services.AddPrompterOneShared(); var host = builder.Build(); -await BrowserCultureRuntime.ApplyPreferredCultureAsync(host.Services.GetRequiredService()); +await host.Services.GetRequiredService().InitializeAsync(); await host.RunAsync(); diff --git a/src/PrompterOne.App/Services/BrowserCultureRuntime.cs b/src/PrompterOne.App/Services/BrowserCultureRuntime.cs deleted file mode 100644 index ae8c12c..0000000 --- a/src/PrompterOne.App/Services/BrowserCultureRuntime.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using Microsoft.JSInterop; -using PrompterOne.Core.Localization; -using PrompterOne.Shared.Services; - -namespace PrompterOne.App.Services; - -internal static class BrowserCultureRuntime -{ - private const string EvaluateMethodName = "eval"; - private const string GetBrowserCulturesExpression = "Array.isArray(window.navigator.languages) && window.navigator.languages.length > 0 ? window.navigator.languages : [window.navigator.language || 'en']"; - private const string GetStoredCultureMethodName = "localStorage.getItem"; - private const string SetDocumentCultureExpressionFormat = "document.documentElement.lang = {0}"; - - public static async Task ApplyPreferredCultureAsync(IJSRuntime jsRuntime) - { - var storedCulture = NormalizeStoredCulture( - await jsRuntime.InvokeAsync(GetStoredCultureMethodName, BrowserStorageKeys.CultureSetting)); - var browserCultures = await jsRuntime.InvokeAsync(EvaluateMethodName, GetBrowserCulturesExpression) ?? []; - var requestedCultures = new[] { storedCulture }.Concat(browserCultures); - var culture = CultureInfo.GetCultureInfo(AppCultureCatalog.ResolvePreferredCulture(requestedCultures)); - CultureInfo.DefaultThreadCurrentCulture = culture; - CultureInfo.DefaultThreadCurrentUICulture = culture; - await jsRuntime.InvokeVoidAsync( - EvaluateMethodName, - string.Format( - CultureInfo.InvariantCulture, - SetDocumentCultureExpressionFormat, - JsonSerializer.Serialize(culture.Name))); - } - - private static string? NormalizeStoredCulture(string? storedCulture) - { - if (string.IsNullOrWhiteSpace(storedCulture)) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(storedCulture) ?? storedCulture; - } - catch (JsonException) - { - return storedCulture; - } - } -} diff --git a/src/PrompterOne.App/wwwroot/index.html b/src/PrompterOne.App/wwwroot/index.html index c493381..d828b8c 100644 --- a/src/PrompterOne.App/wwwroot/index.html +++ b/src/PrompterOne.App/wwwroot/index.html @@ -9,6 +9,7 @@ + diff --git a/src/PrompterOne.Core/Localization/AppCultureCatalog.cs b/src/PrompterOne.Core/Localization/AppCultureCatalog.cs index b294fcc..6a0fa2e 100644 --- a/src/PrompterOne.Core/Localization/AppCultureCatalog.cs +++ b/src/PrompterOne.Core/Localization/AppCultureCatalog.cs @@ -9,12 +9,31 @@ public static class AppCultureCatalog public const string UkrainianCultureName = "uk"; public const string FrenchCultureName = "fr"; public const string SpanishCultureName = "es"; + public const string GermanCultureName = "de"; public const string PortugueseCultureName = "pt"; public const string ItalianCultureName = "it"; private const char CultureSeparator = '-'; private const char AlternateCultureSeparator = '_'; + private const string EnglishDisplayName = "English"; + private const string FrenchDisplayName = "Français"; + private const string GermanDisplayName = "Deutsch"; + private const string ItalianDisplayName = "Italiano"; + private const string PortugueseDisplayName = "Português"; private const string RussianLanguageName = "ru"; + private const string SpanishDisplayName = "Español"; + private const string UkrainianDisplayName = "Українська"; + + private static readonly IReadOnlyList SupportedCultureDefinitions = + [ + new(EnglishCultureName, EnglishDisplayName), + new(UkrainianCultureName, UkrainianDisplayName), + new(FrenchCultureName, FrenchDisplayName), + new(SpanishCultureName, SpanishDisplayName), + new(ItalianCultureName, ItalianDisplayName), + new(GermanCultureName, GermanDisplayName), + new(PortugueseCultureName, PortugueseDisplayName) + ]; private static readonly HashSet SupportedCultures = new(StringComparer.OrdinalIgnoreCase) { @@ -22,10 +41,13 @@ public static class AppCultureCatalog UkrainianCultureName, FrenchCultureName, SpanishCultureName, + GermanCultureName, PortugueseCultureName, ItalianCultureName }; + public static IReadOnlyList SupportedCultureDefinitionsInDisplayOrder => SupportedCultureDefinitions; + public static IReadOnlyCollection SupportedCultureNames => SupportedCultures; public static string ResolvePreferredCulture(IEnumerable requestedCultures) diff --git a/src/PrompterOne.Core/Localization/AppCultureDefinition.cs b/src/PrompterOne.Core/Localization/AppCultureDefinition.cs new file mode 100644 index 0000000..f72c4c6 --- /dev/null +++ b/src/PrompterOne.Core/Localization/AppCultureDefinition.cs @@ -0,0 +1,3 @@ +namespace PrompterOne.Core.Localization; + +public sealed record AppCultureDefinition(string CultureName, string DisplayName); diff --git a/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs b/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs index 0d895db..95a7418 100644 --- a/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs +++ b/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs @@ -13,9 +13,6 @@ namespace PrompterOne.Shared.Layout; public partial class MainLayout : LayoutComponentBase, IDisposable { private const string GoLiveWidgetIdleElapsed = "00:00:00"; - private const string GoLiveWidgetLiveStateLabel = "Live"; - private const string GoLiveWidgetRecordingStateLabel = "Rec"; - private const string GoLiveWidgetStreamingRecordingStateLabel = "Live + Rec"; private const string RouteChangedLogTemplate = "Route changed to {Location}."; [Inject] private AppBootstrapper Bootstrapper { get; set; } = null!; @@ -64,9 +61,9 @@ public partial class MainLayout : LayoutComponentBase, IDisposable private string GoLiveIndicatorCopy => GoLiveIndicatorState switch { - RecordingStateValue => "Recording active", - StreamingStateValue => "Stream active", - _ => "Ready" + RecordingStateValue => Text(UiTextKey.HeaderGoLiveIndicatorRecording), + StreamingStateValue => Text(UiTextKey.HeaderGoLiveIndicatorStreaming), + _ => Text(UiTextKey.HeaderGoLiveIndicatorReady) }; private string GoLiveIndicatorState => GoLiveSessionState.IsRecordingActive @@ -87,9 +84,9 @@ public partial class MainLayout : LayoutComponentBase, IDisposable private string GoLiveWidgetStateLabel => (GoLiveSessionState.IsStreamActive, GoLiveSessionState.IsRecordingActive) switch { - (true, true) => GoLiveWidgetStreamingRecordingStateLabel, - (true, false) => GoLiveWidgetLiveStateLabel, - (false, true) => GoLiveWidgetRecordingStateLabel, + (true, true) => Text(UiTextKey.HeaderGoLiveWidgetLiveRecording), + (true, false) => Text(UiTextKey.HeaderGoLiveWidgetLive), + (false, true) => Text(UiTextKey.HeaderGoLiveWidgetRecording), _ => GoLiveIndicatorCopy }; diff --git a/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs b/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs index 69fe9d2..41431f9 100644 --- a/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs +++ b/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using PrompterOne.Core.Services.Rsvp; using PrompterOne.Core.Services.Streaming; using PrompterOne.Core.Services.Workspace; +using PrompterOne.Shared.Localization; using PrompterOne.Shared.Services.Diagnostics; using PrompterOne.Shared.Services.Editor; using PrompterOne.Shared.Settings.Services; @@ -64,6 +65,7 @@ public static IServiceCollection AddPrompterOneShared(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/PrompterOne.Shared/Contracts/UiTestIds.cs b/src/PrompterOne.Shared/Contracts/UiTestIds.cs index cb49024..d0e31c7 100644 --- a/src/PrompterOne.Shared/Contracts/UiTestIds.cs +++ b/src/PrompterOne.Shared/Contracts/UiTestIds.cs @@ -218,6 +218,7 @@ public static class Settings public const string AboutVersion = "settings-about-version"; public const string AppearancePanel = "settings-appearance-panel"; public const string AiPanel = "settings-ai-panel"; + public const string LanguageSelect = "settings-language-select"; public const string CameraFrameRate = "settings-camera-frame-rate"; public const string CameraPreviewCard = "settings-camera-preview-card"; public const string CameraPreviewEmpty = "settings-camera-preview-empty"; @@ -256,6 +257,7 @@ public static class Settings public const string NoiseSuppression = "settings-noise-suppression"; public const string Page = "settings-page"; public const string PrimaryMic = "settings-primary-mic"; + public const string Title = "settings-title"; public const string RecordingAudioBitrate = "settings-recording-audio-bitrate"; public const string RecordingAutoRecord = "settings-recording-auto-record"; public const string RecordingPanel = "settings-recording-panel"; diff --git a/src/PrompterOne.Shared/Diagnostics/Components/ConnectivityOverlay.razor b/src/PrompterOne.Shared/Diagnostics/Components/ConnectivityOverlay.razor index af572c4..1567824 100644 --- a/src/PrompterOne.Shared/Diagnostics/Components/ConnectivityOverlay.razor +++ b/src/PrompterOne.Shared/Diagnostics/Components/ConnectivityOverlay.razor @@ -1,7 +1,10 @@ @implements IDisposable @namespace PrompterOne.Shared.Components.Diagnostics +@using Microsoft.Extensions.Localization +@using PrompterOne.Shared.Localization @inject NavigationManager Navigation @inject PrompterOne.Shared.Services.Diagnostics.BrowserConnectivityService Connectivity +@inject IStringLocalizer Localizer @if (Connectivity.IsVisible) { @@ -12,7 +15,7 @@ data-state="@Connectivity.State" data-testid="@UiTestIds.Diagnostics.Connectivity">
- Connection + @Text(UiTextKey.DiagnosticsConnectivityEyebrow)

@Connectivity.Title

@Connectivity.Message

@@ -20,12 +23,12 @@ id="@UiDomIds.Diagnostics.ConnectivityRetry" class="app-shell-btn app-shell-btn-primary" @onclick="HandleRetry" - data-testid="@UiTestIds.Diagnostics.ConnectivityRetry">Retry Now + data-testid="@UiTestIds.Diagnostics.ConnectivityRetry">@Text(UiTextKey.DiagnosticsConnectivityRetryNow) + data-testid="@UiTestIds.Diagnostics.ConnectivityDismiss">@Text(UiTextKey.DiagnosticsDismiss)
@@ -48,6 +51,8 @@ private void HandleConnectivityChanged(object? sender, EventArgs args) => InvokeAsync(StateHasChanged); + private string Text(UiTextKey key) => Localizer[key.ToString()]; + public void Dispose() { Connectivity.Changed -= HandleConnectivityChanged; diff --git a/src/PrompterOne.Shared/Diagnostics/Services/BrowserConnectivityService.cs b/src/PrompterOne.Shared/Diagnostics/Services/BrowserConnectivityService.cs index 3782e13..3b6ab96 100644 --- a/src/PrompterOne.Shared/Diagnostics/Services/BrowserConnectivityService.cs +++ b/src/PrompterOne.Shared/Diagnostics/Services/BrowserConnectivityService.cs @@ -1,19 +1,20 @@ +using Microsoft.Extensions.Localization; using Microsoft.JSInterop; +using PrompterOne.Shared.Localization; namespace PrompterOne.Shared.Services.Diagnostics; -public sealed class BrowserConnectivityService(IJSRuntime jsRuntime) : IDisposable, IAsyncDisposable +public sealed class BrowserConnectivityService( + IJSRuntime jsRuntime, + IStringLocalizer localizer) : IDisposable, IAsyncDisposable { - private const string ConnectivityOfflineMessage = "PrompterOne is offline. Live routing, cloud sync, and remote publishing will resume when the browser reconnects."; - private const string ConnectivityOfflineTitle = "Connection lost"; - private const string ConnectivityOnlineMessage = "The browser connection is back. Continue working or reload if anything still looks stale."; - private const string ConnectivityOnlineTitle = "Connection restored"; private const string EvaluateMethodName = "eval"; private const string OnlineExpression = "navigator.onLine"; private const int OnlineAutoHideDelayMilliseconds = 2400; private const int PollIntervalMilliseconds = 1000; private readonly IJSRuntime _jsRuntime = jsRuntime; + private readonly IStringLocalizer _localizer = localizer; private readonly SemaphoreSlim _startGate = new(1, 1); private CancellationTokenSource? _hideCts; @@ -131,8 +132,8 @@ private async Task ProbeAsync(CancellationToken cancellationToken) UpdateState( isVisible: true, state: ConnectivityStateValues.Offline, - title: ConnectivityOfflineTitle, - message: ConnectivityOfflineMessage); + title: Text(UiTextKey.DiagnosticsConnectivityOfflineTitle), + message: Text(UiTextKey.DiagnosticsConnectivityOfflineMessage)); return; } @@ -142,8 +143,8 @@ private async Task ProbeAsync(CancellationToken cancellationToken) UpdateState( isVisible: true, state: ConnectivityStateValues.Online, - title: ConnectivityOnlineTitle, - message: ConnectivityOnlineMessage); + title: Text(UiTextKey.DiagnosticsConnectivityOnlineTitle), + message: Text(UiTextKey.DiagnosticsConnectivityOnlineMessage)); ScheduleHide(); } @@ -212,6 +213,8 @@ private void UpdateState(bool isVisible, string state, string title, string mess Changed?.Invoke(this, EventArgs.Empty); } + private string Text(UiTextKey key) => _localizer[key.ToString()]; + private static class ConnectivityStateValues { public const string Offline = "offline"; diff --git a/src/PrompterOne.Shared/Localization/AppCulturePreferenceService.cs b/src/PrompterOne.Shared/Localization/AppCulturePreferenceService.cs new file mode 100644 index 0000000..02bb26d --- /dev/null +++ b/src/PrompterOne.Shared/Localization/AppCulturePreferenceService.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.JSInterop; +using PrompterOne.Core.Abstractions; +using PrompterOne.Core.Localization; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Settings.Models; + +namespace PrompterOne.Shared.Localization; + +public sealed class AppCulturePreferenceService( + IJSRuntime jsRuntime, + IUserSettingsStore settingsStore, + ILogger? logger = null) +{ + private const string ApplyDocumentLanguageFailureMessage = "Failed to apply the browser document language."; + private const string InitializeFailureMessage = "Failed to initialize the browser culture preference."; + private const string LoadBrowserLanguagesFailureMessage = "Failed to read browser languages."; + + private readonly IJSRuntime _jsRuntime = jsRuntime; + private readonly ILogger _logger = logger ?? NullLogger.Instance; + private readonly IUserSettingsStore _settingsStore = settingsStore; + + private bool _initialized; + + public async Task InitializeAsync(CancellationToken cancellationToken = default) + { + if (_initialized) + { + return; + } + + _initialized = true; + + try + { + var preferences = await _settingsStore.LoadAsync( + SettingsPagePreferences.StorageKey, + cancellationToken) + ?? SettingsPagePreferences.Default; + var storedPreferenceCulture = NormalizeStoredCulture(preferences.LanguageCulture); + var legacyCulture = await LoadLegacyCultureAsync(cancellationToken); + var browserCultures = await LoadBrowserCulturesAsync(cancellationToken); + + var preferredCulture = !string.IsNullOrWhiteSpace(storedPreferenceCulture) + ? AppCultureCatalog.ResolveSupportedCulture(storedPreferenceCulture) + : !string.IsNullOrWhiteSpace(legacyCulture) + ? AppCultureCatalog.ResolveSupportedCulture(legacyCulture) + : AppCultureCatalog.ResolvePreferredCulture(browserCultures); + + ApplyCulture(preferredCulture); + await ApplyDocumentLanguageAsync(preferredCulture, cancellationToken); + await MigrateLegacyCultureAsync(preferences, storedPreferenceCulture, legacyCulture, cancellationToken); + } + catch (Exception exception) + { + _initialized = false; + _logger.LogError(exception, InitializeFailureMessage); + throw; + } + } + + private async Task ApplyDocumentLanguageAsync(string cultureName, CancellationToken cancellationToken) + { + try + { + await _jsRuntime.InvokeVoidAsync( + BrowserCultureInteropMethodNames.SetDocumentLanguage, + cancellationToken, + cultureName); + } + catch (Exception exception) + { + _logger.LogError(exception, ApplyDocumentLanguageFailureMessage); + throw; + } + } + + private static void ApplyCulture(string cultureName) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + } + + private async Task> LoadBrowserCulturesAsync(CancellationToken cancellationToken) + { + try + { + return await _jsRuntime.InvokeAsync( + BrowserCultureInteropMethodNames.GetBrowserLanguages, + cancellationToken) + ?? []; + } + catch (Exception exception) + { + _logger.LogError(exception, LoadBrowserLanguagesFailureMessage); + throw; + } + } + + private async Task LoadLegacyCultureAsync(CancellationToken cancellationToken) + { + var storedCulture = await _jsRuntime.InvokeAsync( + BrowserStorageMethodNames.LoadStorageValue, + cancellationToken, + BrowserStorageKeys.CultureSetting); + + return NormalizeStoredCulture(storedCulture); + } + + private async Task MigrateLegacyCultureAsync( + SettingsPagePreferences preferences, + string? storedPreferenceCulture, + string? legacyCulture, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(legacyCulture)) + { + return; + } + + if (!string.IsNullOrWhiteSpace(storedPreferenceCulture)) + { + await RemoveLegacyCultureAsync(cancellationToken); + return; + } + + var migratedCulture = AppCultureCatalog.ResolveSupportedCulture(legacyCulture); + var updatedPreferences = preferences with + { + LanguageCulture = migratedCulture + }; + + await _settingsStore.SaveAsync(SettingsPagePreferences.StorageKey, updatedPreferences, cancellationToken); + await RemoveLegacyCultureAsync(cancellationToken); + } + + private Task RemoveLegacyCultureAsync(CancellationToken cancellationToken) + { + return _jsRuntime.InvokeVoidAsync( + BrowserStorageMethodNames.RemoveStorageValue, + cancellationToken, + BrowserStorageKeys.CultureSetting).AsTask(); + } + + private static string? NormalizeStoredCulture(string? storedCulture) + { + if (string.IsNullOrWhiteSpace(storedCulture)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(storedCulture) ?? storedCulture; + } + catch (JsonException) + { + return storedCulture; + } + } +} diff --git a/src/PrompterOne.Shared/Localization/BrowserCultureInteropMethodNames.cs b/src/PrompterOne.Shared/Localization/BrowserCultureInteropMethodNames.cs new file mode 100644 index 0000000..d6c8cdd --- /dev/null +++ b/src/PrompterOne.Shared/Localization/BrowserCultureInteropMethodNames.cs @@ -0,0 +1,7 @@ +namespace PrompterOne.Shared.Localization; + +internal static class BrowserCultureInteropMethodNames +{ + public const string GetBrowserLanguages = "prompterOneCulture.getBrowserLanguages"; + public const string SetDocumentLanguage = "prompterOneCulture.setDocumentLanguage"; +} diff --git a/src/PrompterOne.Shared/Localization/SharedResource.de.resx b/src/PrompterOne.Shared/Localization/SharedResource.de.resx new file mode 100644 index 0000000..6d4044f --- /dev/null +++ b/src/PrompterOne.Shared/Localization/SharedResource.de.resx @@ -0,0 +1,291 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Schließen + + + Erneut versuchen + + + Bibliothek + + + Unerwarteter Fehler + + + PrompterOne hat einen unerwarteten Fehler festgestellt. Versuchen Sie diesen Bildschirm erneut oder kehren Sie zur Bibliothek zurück. + + + Aktion fehlgeschlagen + + + Alle Skripte + + + Zuletzt verwendet + + + Favoriten + + + Ordner + + + Neuer Ordner + + + Einstellungen + + + Sortieren nach + + + Name + + + Datum + + + Dauer + + + WPM + + + Ordner erstellen + + + Organisieren Sie Skripte in verschachtelten Sammlungen, ohne das Bibliotheksraster zu verändern. + + + Suchen... + + + Neues Skript + + + Lernen + + + Lesen + + + Live gehen + + + Einstellungen + + + LIVE-ROUTING + + + Aktivieren Sie Ziele für den aktuellen Program-Feed, während Teleprompter und RSVP in separaten Tabs bereit bleiben. + + + Name + + + Übergeordnet + + + Oberste Ebene + + + Abbrechen + + + Erstellen + + + Roadshows + + + Verbindung + + + Jetzt erneut versuchen + + + Verbindung verloren + + + PrompterOne ist offline. Live-Routing, Cloud-Synchronisierung und Remote-Veröffentlichung werden fortgesetzt, sobald der Browser wieder verbunden ist. + + + Verbindung wiederhergestellt + + + Die Browserverbindung ist wieder da. Arbeiten Sie weiter oder laden Sie neu, falls noch etwas veraltet wirkt. + + + Aufnahme aktiv + + + Stream aktiv + + + Bereit + + + Live + + + REC + + + Live + REC + + + Einstellungen + + + Cloud-Sync + + + Dateispeicher + + + Kameras + + + Mikrofone + + + Streaming + + + Aufnahme + + + KI-Anbieter + + + Erscheinungsbild + + + Über + + + Cloud-Speicher + + + Konfigurieren Sie hier optionale Cloud-Snapshot-Ziele. Anbieter-Anmeldedaten bleiben im lokalen Speicher dieses Browsers, und nichts wird verbunden, bis Sie gültige echte Zugangsdaten speichern. + + + Dateispeicher + + + PrompterOne ist browserbasiert. Skripte bleiben im Browser-Bibliotheksspeicher, während Aufnahmen und Exporte den browserlokalen Speichercontainer statt fiktiver Desktop-Ordner verwenden. + + + KI-Anbieter + + + Speichern Sie KI-Anbieter-Entwürfe lokal in diesem Browser. Laufzeit-Verbindungstests sind noch nicht implementiert, daher bleibt jeder Anbieter getrennt, bis eine echte Integration verfügbar ist. + + + Aufnahme + + + Konfigurieren Sie, wie lokale Aufnahmen während Go-Live-Sitzungen gespeichert werden. + + + Streaming-System + + + Konfigurieren Sie hier browserseitige Program-Erfassung, lokale Aufnahme, Transportverbindungen und nachgelagerte Ziele, damit Go Live auf den Betrieb der Sitzung fokussiert bleibt. + + + Erscheinungsbild + + + Passen Sie das Aussehen der App und des Teleprompter-Lesers an. + + + Thema + + + Dunkel · Goldakzent + + + Hell · Eigener Akzent + + + System · Eigener Akzent + + + Farbschema + + + Akzentfarbe + + + Sprache + + + Dunkel + + + Hell + + + System + + + Teleprompter-Anzeige + + + Schrift · Größe · Spiegeln · Hintergrund + + + Schrift + + + Standard-Schriftgröße + + + Textfarbe + + + Text spiegeln (horizontal) + + + Worthervorhebung anzeigen + + + Oberfläche + + + Dichte · Animationen · Seitenleiste + + + UI-Dichte + + + Kompakt + + + Standard + + + Großzügig + + + Bewegung reduzieren + + + Tastenkürzel-Overlay anzeigen + + diff --git a/src/PrompterOne.Shared/Localization/SharedResource.es.resx b/src/PrompterOne.Shared/Localization/SharedResource.es.resx index 4e31525..a176129 100644 --- a/src/PrompterOne.Shared/Localization/SharedResource.es.resx +++ b/src/PrompterOne.Shared/Localization/SharedResource.es.resx @@ -111,4 +111,181 @@ Roadshows + + Conexión + + + Reintentar ahora + + + Conexión perdida + + + PrompterOne está sin conexión. El ruteo en vivo, la sincronización en la nube y la publicación remota se reanudarán cuando el navegador vuelva a conectarse. + + + Conexión restablecida + + + La conexión del navegador ha vuelto. Sigue trabajando o recarga si algo todavía parece desactualizado. + + + Grabación activa + + + Emisión activa + + + Listo + + + En vivo + + + REC + + + En vivo + REC + + + Configuración + + + Sincronización en la nube + + + Almacenamiento de archivos + + + Cámaras + + + Micrófonos + + + Streaming + + + Grabación + + + Proveedor de IA + + + Apariencia + + + Acerca de + + + Almacenamiento en la nube + + + Configura aquí destinos opcionales de instantáneas en la nube. Las credenciales de los proveedores permanecen en el almacenamiento local de este navegador y nada se conecta hasta que guardes credenciales reales válidas. + + + Almacenamiento de archivos + + + PrompterOne funciona solo en el navegador. Los guiones permanecen en la biblioteca del navegador, mientras que las grabaciones y exportaciones usan el contenedor de almacenamiento local del navegador en lugar de carpetas falsas de escritorio. + + + Proveedor de IA + + + Guarda borradores de proveedores de IA localmente en este navegador. Las pruebas de conexión en tiempo de ejecución aún no están implementadas, así que cada proveedor permanece desconectado hasta que exista una integración real. + + + Grabación + + + Configura cómo se guardan las grabaciones locales durante las sesiones de Go Live. + + + Sistema de streaming + + + Configura aquí la captura del programa en el navegador, la grabación local, las conexiones de transporte y los destinos posteriores para que Go Live siga centrado en operar la sesión. + + + Apariencia + + + Personaliza el aspecto de la aplicación y del lector del teleprompter. + + + Tema + + + Oscuro · Acento dorado + + + Claro · Acento personalizado + + + Sistema · Acento personalizado + + + Esquema de color + + + Color de acento + + + Idioma + + + Oscuro + + + Claro + + + Sistema + + + Pantalla del teleprompter + + + Fuente · Tamaño · Espejo · Fondo + + + Fuente + + + Tamaño de fuente predeterminado + + + Color del texto + + + Espejar texto (volteo horizontal) + + + Mostrar resaltado de palabras + + + Interfaz + + + Densidad · Animaciones · Barra lateral + + + Densidad de la interfaz + + + Compacta + + + Predeterminada + + + Espaciosa + + + Reducir movimiento + + + Mostrar panel de atajos de teclado + diff --git a/src/PrompterOne.Shared/Localization/SharedResource.fr.resx b/src/PrompterOne.Shared/Localization/SharedResource.fr.resx index 877ca1a..b77420b 100644 --- a/src/PrompterOne.Shared/Localization/SharedResource.fr.resx +++ b/src/PrompterOne.Shared/Localization/SharedResource.fr.resx @@ -111,4 +111,181 @@ Roadshows + + Connexion + + + Réessayer maintenant + + + Connexion perdue + + + PrompterOne est hors ligne. Le routage en direct, la synchronisation cloud et la publication distante reprendront lorsque le navigateur se reconnectera. + + + Connexion rétablie + + + La connexion du navigateur est revenue. Continuez à travailler ou rechargez si quelque chose semble encore obsolète. + + + Enregistrement actif + + + Diffusion active + + + Prêt + + + En direct + + + REC + + + Direct + REC + + + Paramètres + + + Synchronisation cloud + + + Stockage des fichiers + + + Caméras + + + Microphones + + + Diffusion + + + Enregistrement + + + Fournisseur IA + + + Apparence + + + À propos + + + Stockage cloud + + + Configurez ici des cibles optionnelles de sauvegarde cloud. Les identifiants des fournisseurs restent dans le stockage local de ce navigateur et rien n’est connecté tant que vous n’avez pas enregistré de vrais identifiants valides. + + + Stockage des fichiers + + + PrompterOne fonctionne uniquement dans le navigateur. Les scripts restent dans la bibliothèque du navigateur, tandis que les enregistrements et les exports utilisent le conteneur de stockage local du navigateur au lieu de faux dossiers du bureau. + + + Fournisseur IA + + + Stockez les brouillons des fournisseurs IA localement dans ce navigateur. Les tests de connexion d’exécution ne sont pas encore implémentés, donc chaque fournisseur reste déconnecté jusqu’à l’arrivée d’une vraie intégration. + + + Enregistrement + + + Configurez la manière dont les enregistrements locaux sont sauvegardés pendant les sessions Go Live. + + + Système de diffusion + + + Configurez ici la capture programme du navigateur, l’enregistrement local, les connexions de transport et les destinations aval pour que Go Live reste centré sur l’exploitation de la session. + + + Apparence + + + Personnalisez l’apparence de l’application et du lecteur du prompteur. + + + Thème + + + Sombre · Accent doré + + + Clair · Accent personnalisé + + + Système · Accent personnalisé + + + Mode de couleur + + + Couleur d’accent + + + Langue + + + Sombre + + + Clair + + + Système + + + Affichage du prompteur + + + Police · Taille · Miroir · Arrière-plan + + + Police + + + Taille de police par défaut + + + Couleur du texte + + + Miroir du texte (retournement horizontal) + + + Afficher la mise en évidence des mots + + + Interface + + + Densité · Animations · Barre latérale + + + Densité de l’interface + + + Compacte + + + Par défaut + + + Aérée + + + Réduire les animations + + + Afficher le panneau des raccourcis clavier + diff --git a/src/PrompterOne.Shared/Localization/SharedResource.it.resx b/src/PrompterOne.Shared/Localization/SharedResource.it.resx index dda468c..758f31d 100644 --- a/src/PrompterOne.Shared/Localization/SharedResource.it.resx +++ b/src/PrompterOne.Shared/Localization/SharedResource.it.resx @@ -111,4 +111,181 @@ Roadshows + + Connessione + + + Riprova ora + + + Connessione persa + + + PrompterOne è offline. Il routing live, la sincronizzazione cloud e la pubblicazione remota riprenderanno quando il browser si riconnetterà. + + + Connessione ripristinata + + + La connessione del browser è tornata. Continua a lavorare o ricarica se qualcosa sembra ancora non aggiornato. + + + Registrazione attiva + + + Streaming attivo + + + Pronto + + + Live + + + REC + + + Live + REC + + + Impostazioni + + + Sincronizzazione cloud + + + Archiviazione file + + + Camere + + + Microfoni + + + Streaming + + + Registrazione + + + Provider IA + + + Aspetto + + + Informazioni + + + Archiviazione cloud + + + Configura qui destinazioni facoltative per snapshot cloud. Le credenziali dei provider restano nel local storage di questo browser e nulla viene connesso finché non salvi credenziali reali valide. + + + Archiviazione file + + + PrompterOne è solo browser. Gli script restano nella libreria del browser, mentre registrazioni ed esportazioni usano il contenitore di archiviazione locale del browser invece di cartelle desktop fittizie. + + + Provider IA + + + Conserva le bozze dei provider IA localmente in questo browser. I test di connessione runtime non sono ancora implementati, quindi ogni provider resta scollegato finché non arriverà una vera integrazione. + + + Registrazione + + + Configura come vengono salvate le registrazioni locali durante le sessioni Go Live. + + + Sistema di streaming + + + Configura qui la cattura del programma nel browser, la registrazione locale, le connessioni di trasporto e le destinazioni downstream così che Go Live resti focalizzato sull’operatività della sessione. + + + Aspetto + + + Personalizza l’aspetto dell’app e del lettore teleprompter. + + + Tema + + + Scuro · Accento dorato + + + Chiaro · Accento personalizzato + + + Sistema · Accento personalizzato + + + Schema colori + + + Colore accento + + + Lingua + + + Scuro + + + Chiaro + + + Sistema + + + Schermo teleprompter + + + Carattere · Dimensione · Specchio · Sfondo + + + Carattere + + + Dimensione carattere predefinita + + + Colore del testo + + + Specchia testo (flip orizzontale) + + + Mostra evidenziazione parole + + + Interfaccia + + + Densità · Animazioni · Barra laterale + + + Densità interfaccia + + + Compatta + + + Predefinita + + + Ampia + + + Riduci movimento + + + Mostra pannello scorciatoie da tastiera + diff --git a/src/PrompterOne.Shared/Localization/SharedResource.pt.resx b/src/PrompterOne.Shared/Localization/SharedResource.pt.resx index 23529ce..2874af3 100644 --- a/src/PrompterOne.Shared/Localization/SharedResource.pt.resx +++ b/src/PrompterOne.Shared/Localization/SharedResource.pt.resx @@ -111,4 +111,181 @@ Roadshows + + Conexão + + + Tentar agora + + + Conexão perdida + + + O PrompterOne está offline. O roteamento ao vivo, a sincronização na nuvem e a publicação remota serão retomados quando o navegador se reconectar. + + + Conexão restaurada + + + A conexão do navegador voltou. Continue trabalhando ou recarregue se algo ainda parecer desatualizado. + + + Gravação ativa + + + Transmissão ativa + + + Pronto + + + Ao vivo + + + REC + + + Ao vivo + REC + + + Configurações + + + Sincronização na nuvem + + + Armazenamento de arquivos + + + Câmeras + + + Microfones + + + Streaming + + + Gravação + + + Provedor de IA + + + Aparência + + + Sobre + + + Armazenamento em nuvem + + + Configure aqui destinos opcionais de snapshot em nuvem. As credenciais dos provedores permanecem no armazenamento local deste navegador e nada é conectado até que você salve credenciais reais válidas. + + + Armazenamento de arquivos + + + O PrompterOne funciona apenas no navegador. Os roteiros permanecem na biblioteca do navegador, enquanto gravações e exportações usam o contêiner de armazenamento local do navegador em vez de pastas falsas da área de trabalho. + + + Provedor de IA + + + Armazene rascunhos de provedores de IA localmente neste navegador. O teste de conexão em tempo de execução ainda não foi implementado, então cada provedor permanece desconectado até que exista uma integração real. + + + Gravação + + + Configure como as gravações locais são salvas durante as sessões do Go Live. + + + Sistema de streaming + + + Configure aqui a captura do programa no navegador, a gravação local, as conexões de transporte e os destinos downstream para que o Go Live continue focado em operar a sessão. + + + Aparência + + + Personalize a aparência do aplicativo e do leitor do teleprompter. + + + Tema + + + Escuro · Destaque dourado + + + Claro · Destaque personalizado + + + Sistema · Destaque personalizado + + + Esquema de cores + + + Cor de destaque + + + Idioma + + + Escuro + + + Claro + + + Sistema + + + Exibição do teleprompter + + + Fonte · Tamanho · Espelho · Fundo + + + Fonte + + + Tamanho de fonte padrão + + + Cor do texto + + + Espelhar texto (virar horizontalmente) + + + Mostrar destaque de palavras + + + Interface + + + Densidade · Animações · Barra lateral + + + Densidade da interface + + + Compacta + + + Padrão + + + Espaçosa + + + Reduzir movimento + + + Mostrar painel de atalhos do teclado + diff --git a/src/PrompterOne.Shared/Localization/SharedResource.resx b/src/PrompterOne.Shared/Localization/SharedResource.resx index 9a1ca73..2e39924 100644 --- a/src/PrompterOne.Shared/Localization/SharedResource.resx +++ b/src/PrompterOne.Shared/Localization/SharedResource.resx @@ -111,4 +111,181 @@ Roadshows + + Connectivity + + + Retry Now + + + Connection lost + + + PrompterOne is offline. Live routing, cloud sync, and remote publishing will resume when the browser reconnects. + + + Connection restored + + + The browser connection is back. Continue working or reload if anything still looks stale. + + + Recording active + + + Stream active + + + Ready + + + Live + + + Rec + + + Live + Rec + + + Settings + + + Cloud Sync + + + File Storage + + + Cameras + + + Microphones + + + Streaming + + + Recording + + + AI Provider + + + Appearance + + + About + + + Cloud 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. + + + File Storage + + + 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. + + + AI Provider + + + 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. + + + Recording + + + Configure how local recordings are saved during Go Live sessions. + + + Streaming System + + + Configure browser-owned program capture, local recording, transport connections, and downstream targets here so Go Live stays focused on operating the session. + + + Appearance + + + Customize the look and feel of the app and the teleprompter reader. + + + Theme + + + Dark · Gold accent + + + Light · Custom accent + + + System · Custom accent + + + Color Scheme + + + Accent Color + + + Language + + + Dark + + + Light + + + System + + + Teleprompter Display + + + Font · Size · Mirror · Background + + + Font + + + Default Font Size + + + Text Color + + + Mirror Text (Horizontal Flip) + + + Show Word Highlight + + + Interface + + + Density · Animations · Sidebar + + + UI Density + + + Compact + + + Default + + + Spacious + + + Reduce Motion + + + Show keyboard shortcuts overlay + diff --git a/src/PrompterOne.Shared/Localization/SharedResource.uk.resx b/src/PrompterOne.Shared/Localization/SharedResource.uk.resx index 8ef96dd..444232c 100644 --- a/src/PrompterOne.Shared/Localization/SharedResource.uk.resx +++ b/src/PrompterOne.Shared/Localization/SharedResource.uk.resx @@ -111,4 +111,181 @@ Роудшоу + + З’єднання + + + Спробувати зараз + + + З’єднання втрачено + + + PrompterOne офлайн. Маршрутизація ефіру, хмарна синхронізація та віддалена публікація відновляться, щойно браузер знову підключиться. + + + З’єднання відновлено + + + З’єднання браузера повернулося. Продовжуйте роботу або перезавантажте сторінку, якщо щось ще виглядає застарілим. + + + Запис активний + + + Ефір активний + + + Готово + + + Ефір + + + REC + + + Ефір + REC + + + Налаштування + + + Хмарна синхронізація + + + Файлове сховище + + + Камери + + + Мікрофони + + + Трансляція + + + Запис + + + Провайдер ШІ + + + Вигляд + + + Про застосунок + + + Хмарне сховище + + + Налаштуйте тут необов’язкові хмарні цілі для знімків. Облікові дані провайдерів залишаються у локальному сховищі цього браузера, і нічого не підключається, доки ви не збережете справжні чинні облікові дані. + + + Файлове сховище + + + PrompterOne працює лише в браузері. Сценарії залишаються у бібліотеці браузера, а записи та експорт використовують локальний контейнер сховища браузера замість фіктивних тек робочого столу. + + + Провайдер ШІ + + + Зберігайте чернетки провайдерів ШІ локально в цьому браузері. Перевірка з’єднання під час виконання ще не реалізована, тому кожен провайдер залишається від’єднаним, доки не з’явиться справжня інтеграція. + + + Запис + + + Налаштуйте, як локальні записи зберігаються під час сесій Go Live. + + + Система трансляції + + + Налаштуйте тут браузерне захоплення програмного сигналу, локальний запис, транспортні з’єднання та кінцеві цілі, щоб Go Live залишався зосередженим на керуванні сесією. + + + Вигляд + + + Налаштуйте вигляд застосунку та читача телесуфлера. + + + Тема + + + Темна · Золотий акцент + + + Світла · Власний акцент + + + Системна · Власний акцент + + + Колірна схема + + + Акцентний колір + + + Мова + + + Темна + + + Світла + + + Системна + + + Вигляд телесуфлера + + + Шрифт · Розмір · Дзеркало · Тло + + + Шрифт + + + Розмір шрифту за замовчуванням + + + Колір тексту + + + Дзеркалити текст (горизонтально) + + + Підсвічувати слова + + + Інтерфейс + + + Щільність · Анімації · Бічна панель + + + Щільність інтерфейсу + + + Щільна + + + Стандартна + + + Простора + + + Зменшити анімацію + + + Показувати панель клавіатурних скорочень + diff --git a/src/PrompterOne.Shared/Localization/UiTextKey.cs b/src/PrompterOne.Shared/Localization/UiTextKey.cs index d000296..0a91d7b 100644 --- a/src/PrompterOne.Shared/Localization/UiTextKey.cs +++ b/src/PrompterOne.Shared/Localization/UiTextKey.cs @@ -8,6 +8,12 @@ public enum UiTextKey DiagnosticsFatalTitle, DiagnosticsFatalMessage, DiagnosticsRecoverableTitle, + DiagnosticsConnectivityEyebrow, + DiagnosticsConnectivityRetryNow, + DiagnosticsConnectivityOfflineTitle, + DiagnosticsConnectivityOfflineMessage, + DiagnosticsConnectivityOnlineTitle, + DiagnosticsConnectivityOnlineMessage, LibraryAllScripts, LibraryRecent, LibraryFavorites, @@ -27,8 +33,61 @@ public enum UiTextKey HeaderRead, HeaderGoLive, HeaderSettings, + HeaderGoLiveIndicatorRecording, + HeaderGoLiveIndicatorStreaming, + HeaderGoLiveIndicatorReady, + HeaderGoLiveWidgetLive, + HeaderGoLiveWidgetRecording, + HeaderGoLiveWidgetLiveRecording, GoLiveHeroEyebrow, GoLiveHeroDescription, + SettingsTitle, + SettingsNavCloud, + SettingsNavFiles, + SettingsNavCameras, + SettingsNavMicrophones, + SettingsNavStreaming, + SettingsNavRecording, + SettingsNavAi, + SettingsNavAppearance, + SettingsNavAbout, + SettingsCloudSectionTitle, + SettingsCloudSectionDescription, + SettingsFilesSectionTitle, + SettingsFilesSectionDescription, + SettingsAiSectionTitle, + SettingsAiSectionDescription, + SettingsRecordingSectionTitle, + SettingsRecordingSectionDescription, + SettingsStreamingSectionTitle, + SettingsStreamingSectionDescription, + SettingsAppearanceSectionTitle, + SettingsAppearanceSectionDescription, + SettingsAppearanceThemeTitle, + SettingsAppearanceThemeSubtitleDark, + SettingsAppearanceThemeSubtitleLight, + SettingsAppearanceThemeSubtitleSystem, + SettingsAppearanceColorSchemeLabel, + SettingsAppearanceAccentColorLabel, + SettingsAppearanceLanguageLabel, + SettingsAppearanceThemeDark, + SettingsAppearanceThemeLight, + SettingsAppearanceThemeSystem, + SettingsAppearanceTeleprompterTitle, + SettingsAppearanceTeleprompterSubtitle, + SettingsAppearanceFontLabel, + SettingsAppearanceDefaultFontSizeLabel, + SettingsAppearanceTextColorLabel, + SettingsAppearanceMirrorTextLabel, + SettingsAppearanceShowWordHighlightLabel, + SettingsAppearanceInterfaceTitle, + SettingsAppearanceInterfaceSubtitle, + SettingsAppearanceUiDensityLabel, + SettingsAppearanceDensityCompact, + SettingsAppearanceDensityDefault, + SettingsAppearanceDensitySpacious, + SettingsAppearanceReduceMotionLabel, + SettingsAppearanceShowShortcutOverlayLabel, CommonName, CommonParent, CommonTopLevel, diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor index 38f6fc4..6835a3c 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor @@ -1,14 +1,17 @@ @namespace PrompterOne.Shared.Components.Settings +@using Microsoft.Extensions.Localization @using PrompterOne.Shared.Pages +@using PrompterOne.Shared.Localization @using PrompterOne.Shared.Settings.Components @using PrompterOne.Shared.Settings.Models +@inject IStringLocalizer Localizer
-

@SettingsNavigationText.AiProviderLabel

-

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.

+

@Text(UiTextKey.SettingsAiSectionTitle)

+

@Text(UiTextKey.SettingsAiSectionDescription)

+ +@code { + private string Text(UiTextKey key) => Localizer[key.ToString()]; +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAppearanceSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAppearanceSection.razor index 91f83e3..e246ec1 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsAppearanceSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAppearanceSection.razor @@ -1,17 +1,20 @@ @namespace PrompterOne.Shared.Components.Settings @using Microsoft.AspNetCore.Components +@using Microsoft.Extensions.Localization +@using PrompterOne.Shared.Localization @using PrompterOne.Shared.Settings.Components @using PrompterOne.Shared.Settings.Models +@inject IStringLocalizer Localizer
-

Appearance

-

Customize the look and feel of the app and the teleprompter reader.

+

@Text(UiTextKey.SettingsAppearanceSectionTitle)

+

@Text(UiTextKey.SettingsAppearanceSectionDescription)

-
- +
@foreach (var option in ThemeOptions) { @@ -47,7 +50,7 @@
- +
@foreach (var accent in AccentOptions) { @@ -60,11 +63,18 @@ }
+
+ + +
- @@ -73,13 +83,13 @@
- +
- +
A
- +
@foreach (var swatch in TextColorOptions) { @@ -107,7 +117,7 @@
- +
@@ -115,7 +125,7 @@
- +
@@ -125,8 +135,8 @@
- @@ -136,7 +146,7 @@
- +
@foreach (var density in DensityOptions) { @@ -153,7 +163,7 @@
- +
@@ -161,7 +171,7 @@
- +
@@ -197,11 +207,11 @@ new("Orange", "#FB923C") ]; - private static readonly ThemeOption[] DensityOptions = + private ThemeOption[] DensityOptions => [ - new(SettingsAppearanceValues.CompactDensity, "Compact", "background:rgba(196,160,96,.08);display:flex;align-items:center;justify-content:center;", ""), - new(SettingsAppearanceValues.DefaultDensity, "Default", "background:rgba(196,160,96,.08);display:flex;align-items:center;justify-content:center;", ""), - new(SettingsAppearanceValues.SpaciousDensity, "Spacious", "background:rgba(196,160,96,.08);display:flex;align-items:center;justify-content:center;", "") + new(SettingsAppearanceValues.CompactDensity, Text(UiTextKey.SettingsAppearanceDensityCompact), "background:rgba(196,160,96,.08);display:flex;align-items:center;justify-content:center;", ""), + new(SettingsAppearanceValues.DefaultDensity, Text(UiTextKey.SettingsAppearanceDensityDefault), "background:rgba(196,160,96,.08);display:flex;align-items:center;justify-content:center;", ""), + new(SettingsAppearanceValues.SpaciousDensity, Text(UiTextKey.SettingsAppearanceDensitySpacious), "background:rgba(196,160,96,.08);display:flex;align-items:center;justify-content:center;", "") ]; private static readonly ThemeOption[] TextColorOptions = @@ -212,25 +222,26 @@ new("Mint", "#6EE7B7") ]; - private static readonly ThemeOption[] ThemeOptions = + private ThemeOption[] ThemeOptions => [ - new(SettingsAppearanceValues.DarkColorScheme, "Dark", "background:linear-gradient(135deg,#0a0e14,#141c28)", ""), - new(SettingsAppearanceValues.LightColorScheme, "Light", "background:linear-gradient(135deg,#f0ebe4,#e0d8cc)", ""), - new(SettingsAppearanceValues.SystemColorScheme, "System", "background:linear-gradient(135deg,#0a0e14 50%,#f0ebe4 50%)", "") + new(SettingsAppearanceValues.DarkColorScheme, Text(UiTextKey.SettingsAppearanceThemeDark), "background:linear-gradient(135deg,#0a0e14,#141c28)", ""), + new(SettingsAppearanceValues.LightColorScheme, Text(UiTextKey.SettingsAppearanceThemeLight), "background:linear-gradient(135deg,#f0ebe4,#e0d8cc)", ""), + new(SettingsAppearanceValues.SystemColorScheme, Text(UiTextKey.SettingsAppearanceThemeSystem), "background:linear-gradient(135deg,#0a0e14 50%,#f0ebe4 50%)", "") ]; private string ThemeSubtitle => ColorScheme switch { - SettingsAppearanceValues.LightColorScheme => "Light · Custom accent", - SettingsAppearanceValues.SystemColorScheme => "System · Custom accent", - _ => "Dark · Gold accent" + SettingsAppearanceValues.LightColorScheme => Text(UiTextKey.SettingsAppearanceThemeSubtitleLight), + SettingsAppearanceValues.SystemColorScheme => Text(UiTextKey.SettingsAppearanceThemeSubtitleSystem), + _ => Text(UiTextKey.SettingsAppearanceThemeSubtitleDark) }; [Parameter] public string AccentColor { get; set; } = string.Empty; [Parameter] public string ColorScheme { get; set; } = string.Empty; [Parameter] public string DisplayStyle { get; set; } = string.Empty; [Parameter] public Func IsCardOpen { get; set; } = static _ => false; + [Parameter] public string SelectedLanguageCulture { get; set; } = string.Empty; [Parameter] public bool MirrorTeleprompterText { get; set; } [Parameter] public bool ReduceMotion { get; set; } [Parameter] public bool ShowShortcutOverlay { get; set; } @@ -246,14 +257,25 @@ [Parameter] public EventCallback ToggleShowWordHighlight { get; set; } [Parameter] public EventCallback UpdateAccentColor { get; set; } [Parameter] public EventCallback UpdateColorScheme { get; set; } + [Parameter] public EventCallback UpdateLanguageCulture { get; set; } [Parameter] public EventCallback UpdateTeleprompterFont { get; set; } [Parameter] public EventCallback UpdateTeleprompterFontSize { get; set; } [Parameter] public EventCallback UpdateTeleprompterTextColor { get; set; } [Parameter] public EventCallback UpdateUiDensity { get; set; } + private static readonly IReadOnlyList LanguageOptions = PrompterOne.Core.Localization.AppCultureCatalog + .SupportedCultureDefinitionsInDisplayOrder + .Select(culture => new SettingsSelectOption(culture.CultureName, culture.DisplayName)) + .ToArray(); + + private string Text(UiTextKey key) => Localizer[key.ToString()]; + private void OnFontChanged(ChangeEventArgs args) => UpdateTeleprompterFont.InvokeAsync(args.Value?.ToString() ?? string.Empty); + private void OnLanguageChanged(ChangeEventArgs args) => + UpdateLanguageCulture.InvokeAsync(args.Value?.ToString() ?? string.Empty); + private sealed record ThemeOption(string Label, string Value) { public ThemeOption(string value, string label, string swatchStyle, string iconMarkup) : this(label, value) diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor index 3778bab..17b2412 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsCloudSection.razor @@ -1,14 +1,17 @@ @namespace PrompterOne.Shared.Components.Settings @using ManagedCode.Storage.CloudKit.Options +@using Microsoft.Extensions.Localization +@using PrompterOne.Shared.Localization @using PrompterOne.Shared.Settings.Components @using PrompterOne.Shared.Storage.Cloud +@inject IStringLocalizer Localizer
-

Cloud 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.

+

@Text(UiTextKey.SettingsCloudSectionTitle)

+

@Text(UiTextKey.SettingsCloudSectionDescription)

Sync Defaults

@@ -262,3 +265,7 @@
+ +@code { + private string Text(UiTextKey key) => Localizer[key.ToString()]; +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor index 1fb5d56..21cd0d3 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor @@ -1,13 +1,16 @@ @namespace PrompterOne.Shared.Components.Settings +@using Microsoft.Extensions.Localization +@using PrompterOne.Shared.Localization @using PrompterOne.Shared.Settings.Components @using PrompterOne.Shared.Settings.Models +@inject IStringLocalizer Localizer
-

@SettingsNavigationText.FileStorageLabel

-

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.

+

@Text(UiTextKey.SettingsFilesSectionTitle)

+

@Text(UiTextKey.SettingsFilesSectionDescription)

+ +@code { + private string Text(UiTextKey key) => Localizer[key.ToString()]; +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsRecordingSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsRecordingSection.razor index e69f9d3..187409f 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsRecordingSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsRecordingSection.razor @@ -1,12 +1,15 @@ @namespace PrompterOne.Shared.Components.Settings +@using Microsoft.Extensions.Localization +@using PrompterOne.Shared.Localization @using PrompterOne.Shared.Settings.Components +@inject IStringLocalizer Localizer
-

Recording

-

Configure how local recordings are saved during GO LIVE sessions.

+

@Text(UiTextKey.SettingsRecordingSectionTitle)

+

@Text(UiTextKey.SettingsRecordingSectionDescription)

+@code { + private string Text(UiTextKey key) => Localizer[key.ToString()]; +} + @code { private static readonly IReadOnlyList AudioChannelsOptions = [ diff --git a/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs b/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs index d3ec139..44da589 100644 --- a/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs +++ b/src/PrompterOne.Shared/Settings/Models/SettingsPagePreferences.cs @@ -25,7 +25,8 @@ public sealed record SettingsPagePreferences( bool ReduceMotion, bool ShowShortcutOverlay, bool ShowHeaderChrome, - bool AmbientGradientMotion) + bool AmbientGradientMotion, + string? LanguageCulture) { public const string StorageKey = "prompterone.settings-page"; @@ -54,5 +55,6 @@ public sealed record SettingsPagePreferences( ReduceMotion: false, ShowShortcutOverlay: true, ShowHeaderChrome: true, - AmbientGradientMotion: true); + AmbientGradientMotion: true, + LanguageCulture: null); } diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Localization.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Localization.cs new file mode 100644 index 0000000..1b1ecd2 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Localization.cs @@ -0,0 +1,20 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using PrompterOne.Core.Localization; +using PrompterOne.Shared.Localization; + +namespace PrompterOne.Shared.Pages; + +public partial class SettingsPage +{ + [Inject] private IStringLocalizer Localizer { get; set; } = null!; + [Inject] private NavigationManager Navigation { get; set; } = null!; + + private string SelectedLanguageCulture => + string.IsNullOrWhiteSpace(_pagePreferences.LanguageCulture) + ? AppCultureCatalog.ResolveSupportedCulture(CultureInfo.CurrentUICulture.Name) + : AppCultureCatalog.ResolveSupportedCulture(_pagePreferences.LanguageCulture); + + private string Text(UiTextKey key) => Localizer[key.ToString()]; +} diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs index d5b85c5..d32b0cb 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Preferences.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Components; +using PrompterOne.Core.Localization; using PrompterOne.Shared.Services; using PrompterOne.Shared.Settings.Models; @@ -208,6 +209,14 @@ private async Task ToggleShowShortcutOverlayAsync() await PersistPreferencesAsync(); } + private async Task UpdateLanguageCultureAsync(string value) + { + var cultureName = AppCultureCatalog.ResolveSupportedCulture(value); + _pagePreferences = _pagePreferences with { LanguageCulture = cultureName }; + await PersistPreferencesAsync(); + Navigation.NavigateTo(Navigation.Uri, forceLoad: true); + } + private static string BuildToggleCssClass(bool isOn) => isOn ? $"{SetToggleCssClass} {OnCssClass}" : SetToggleCssClass; } diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor index cf0b4df..b5e93c0 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor @@ -5,6 +5,7 @@ @using PrompterOne.Core.Models.Media @using PrompterOne.Core.Models.Workspace @using PrompterOne.Shared.Components.Settings +@using PrompterOne.Shared.Localization @using PrompterOne.Shared.Services @using PrompterOne.Shared.Services.Diagnostics @using PrompterOne.Shared.Settings.Components @@ -25,44 +26,44 @@
-

Settings

+

@Text(UiTextKey.SettingsTitle)

@@ -136,8 +137,8 @@ id="set-streaming" style="@GetSectionDisplayStyle(SettingsSection.Streaming)" data-testid="@UiTestIds.Settings.StreamingPanel"> -

Streaming System

-

Configure browser-owned program capture, local recording, transport connections, and downstream targets here so Go Live stays focused on operating the session.

+

@Text(UiTextKey.SettingsStreamingSectionTitle)

+

@Text(UiTextKey.SettingsStreamingSectionDescription)

diff --git a/src/PrompterOne.Shared/wwwroot/localization/browser-culture.js b/src/PrompterOne.Shared/wwwroot/localization/browser-culture.js new file mode 100644 index 0000000..49f8865 --- /dev/null +++ b/src/PrompterOne.Shared/wwwroot/localization/browser-culture.js @@ -0,0 +1,27 @@ +(() => { + const defaultCulture = "en"; + const namespace = "prompterOneCulture"; + + function getBrowserLanguages() { + const browserLanguages = Array.isArray(window.navigator.languages) && window.navigator.languages.length > 0 + ? window.navigator.languages + : [window.navigator.language || defaultCulture]; + + return browserLanguages + .filter(value => typeof value === "string") + .map(value => value.trim()) + .filter(value => value.length > 0); + } + + function setDocumentLanguage(cultureName) { + const resolvedCulture = typeof cultureName === "string" && cultureName.trim().length > 0 + ? cultureName.trim() + : defaultCulture; + document.documentElement.lang = resolvedCulture; + } + + window[namespace] = Object.freeze({ + getBrowserLanguages, + setDocumentLanguage + }); +})(); diff --git a/tests/PrompterOne.App.Tests/Localization/AppCulturePreferenceServiceTests.cs b/tests/PrompterOne.App.Tests/Localization/AppCulturePreferenceServiceTests.cs new file mode 100644 index 0000000..2c6183d --- /dev/null +++ b/tests/PrompterOne.App.Tests/Localization/AppCulturePreferenceServiceTests.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using PrompterOne.Core.Abstractions; +using PrompterOne.Core.Localization; +using PrompterOne.Shared.Localization; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Settings.Models; +using PrompterOne.Shared.Tests; + +namespace PrompterOne.App.Tests; + +public sealed class AppCulturePreferenceServiceTests : BunitContext +{ + private readonly TestJsRuntime _jsRuntime; + private readonly AppCulturePreferenceService _service; + private readonly IUserSettingsStore _settingsStore; + + public AppCulturePreferenceServiceTests() + { + TestHarnessFactory.Create(this, seedLibraryData: false); + _jsRuntime = Services.GetRequiredService(); + _service = Services.GetRequiredService(); + _settingsStore = Services.GetRequiredService(); + } + + [Fact] + public async Task InitializeAsync_UsesSavedPreferenceBeforeBrowserCulture() + { + using var _ = new CultureResetScope(); + _jsRuntime.SetBrowserLanguages("de-DE", "en-US"); + await _settingsStore.SaveAsync( + SettingsPagePreferences.StorageKey, + SettingsPagePreferences.Default with { LanguageCulture = AppCultureCatalog.FrenchCultureName }); + + await _service.InitializeAsync(); + + Assert.Equal(AppCultureCatalog.FrenchCultureName, CultureInfo.CurrentCulture.Name); + Assert.Equal(AppCultureCatalog.FrenchCultureName, CultureInfo.CurrentUICulture.Name); + Assert.Equal(AppCultureCatalog.FrenchCultureName, _jsRuntime.DocumentLanguage); + } + + [Fact] + public async Task InitializeAsync_UsesSupportedBrowserCulture_WhenUserPreferenceIsMissing() + { + using var _ = new CultureResetScope(); + _jsRuntime.SetBrowserLanguages("de-DE", "en-US"); + + await _service.InitializeAsync(); + + Assert.Equal(AppCultureCatalog.GermanCultureName, CultureInfo.CurrentUICulture.Name); + Assert.Equal(AppCultureCatalog.GermanCultureName, _jsRuntime.DocumentLanguage); + } + + [Fact] + public async Task InitializeAsync_MigratesLegacyCultureSetting_WhenTypedPreferenceIsMissing() + { + using var _ = new CultureResetScope(); + _jsRuntime.SavedValues[BrowserStorageKeys.CultureSetting] = AppCultureCatalog.GermanCultureName; + _jsRuntime.SetBrowserLanguages("en-US"); + + await _service.InitializeAsync(); + + var savedPreferences = await _settingsStore.LoadAsync(SettingsPagePreferences.StorageKey); + + Assert.NotNull(savedPreferences); + Assert.Equal(AppCultureCatalog.GermanCultureName, savedPreferences!.LanguageCulture); + Assert.Equal(AppCultureCatalog.GermanCultureName, CultureInfo.CurrentUICulture.Name); + Assert.Equal(AppCultureCatalog.GermanCultureName, _jsRuntime.DocumentLanguage); + Assert.DoesNotContain(BrowserStorageKeys.CultureSetting, _jsRuntime.SavedValues.Keys); + Assert.DoesNotContain(BrowserStorageKeys.CultureSetting, _jsRuntime.SavedJsonValues.Keys); + } + + private sealed class CultureResetScope : IDisposable + { + private readonly CultureInfo _originalCulture = CultureInfo.CurrentCulture; + private readonly CultureInfo _originalUiCulture = CultureInfo.CurrentUICulture; + private readonly CultureInfo? _originalDefaultCulture = CultureInfo.DefaultThreadCurrentCulture; + private readonly CultureInfo? _originalDefaultUiCulture = CultureInfo.DefaultThreadCurrentUICulture; + + public void Dispose() + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUiCulture; + CultureInfo.DefaultThreadCurrentCulture = _originalDefaultCulture; + CultureInfo.DefaultThreadCurrentUICulture = _originalDefaultUiCulture; + } + } +} diff --git a/tests/PrompterOne.App.Tests/Localization/LocalizationRenderingTests.cs b/tests/PrompterOne.App.Tests/Localization/LocalizationRenderingTests.cs index 3229af8..5d1f8e4 100644 --- a/tests/PrompterOne.App.Tests/Localization/LocalizationRenderingTests.cs +++ b/tests/PrompterOne.App.Tests/Localization/LocalizationRenderingTests.cs @@ -7,7 +7,9 @@ using PrompterOne.Shared.Components.Diagnostics; using PrompterOne.Shared.Components.GoLive; using PrompterOne.Shared.Components.Library; +using PrompterOne.Shared.Components.Settings; using PrompterOne.Shared.Localization; +using PrompterOne.Shared.Settings.Models; using PrompterOne.Shared.Tests; namespace PrompterOne.App.Tests; @@ -94,6 +96,40 @@ public void GoLiveHero_RendersLocalizedDefaults_WhenCurrentCultureIsUkrainian() Assert.Contains(Text(UiTextKey.HeaderRead), cut.Markup); } + [Fact] + public void SettingsAppearanceSection_RendersGermanLabels_WhenCurrentCultureIsGerman() + { + using var _ = new CultureScope(AppCultureCatalog.GermanCultureName); + + var cut = Render(parameters => parameters + .Add(component => component.DisplayStyle, string.Empty) + .Add(component => component.IsCardOpen, static _ => true) + .Add(component => component.ColorScheme, SettingsAppearanceValues.DarkColorScheme) + .Add(component => component.AccentColor, SettingsAppearanceValues.DefaultAccentColor) + .Add(component => component.SelectedLanguageCulture, AppCultureCatalog.GermanCultureName) + .Add(component => component.TeleprompterFont, "Inter (Default)") + .Add(component => component.TeleprompterFontSize, 48) + .Add(component => component.TeleprompterTextColor, "#FFFFFF") + .Add(component => component.UiDensity, SettingsAppearanceValues.DefaultDensity) + .Add(component => component.ToggleCard, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.UpdateColorScheme, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.UpdateAccentColor, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.UpdateLanguageCulture, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.UpdateTeleprompterFont, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.UpdateTeleprompterFontSize, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.UpdateTeleprompterTextColor, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.ToggleMirrorTeleprompterText, EventCallback.Factory.Create(this, () => Task.CompletedTask)) + .Add(component => component.ToggleShowWordHighlight, EventCallback.Factory.Create(this, () => Task.CompletedTask)) + .Add(component => component.UpdateUiDensity, EventCallback.Factory.Create(this, _ => Task.CompletedTask)) + .Add(component => component.ToggleReduceMotion, EventCallback.Factory.Create(this, () => Task.CompletedTask)) + .Add(component => component.ToggleShowShortcutOverlay, EventCallback.Factory.Create(this, () => Task.CompletedTask))); + + Assert.Contains(Text(UiTextKey.SettingsAppearanceSectionTitle), cut.Markup); + Assert.Contains(Text(UiTextKey.SettingsAppearanceLanguageLabel), cut.Markup); + Assert.Contains(Text(UiTextKey.SettingsAppearanceThemeDark), cut.Markup); + Assert.Contains(Text(UiTextKey.SettingsAppearanceUiDensityLabel), cut.Markup); + } + private string Text(UiTextKey key) => Services.GetRequiredService>()[key.ToString()]; diff --git a/tests/PrompterOne.App.Tests/Support/TestSupport.cs b/tests/PrompterOne.App.Tests/Support/TestSupport.cs index 16f4360..075cdfb 100644 --- a/tests/PrompterOne.App.Tests/Support/TestSupport.cs +++ b/tests/PrompterOne.App.Tests/Support/TestSupport.cs @@ -5,6 +5,7 @@ using Microsoft.JSInterop; using PrompterOne.App.Tests; using PrompterOne.Core.Abstractions; +using PrompterOne.Core.Localization; using PrompterOne.Core.Models.Documents; using PrompterOne.Core.Models.Library; using PrompterOne.Core.Models.Media; @@ -16,6 +17,7 @@ using PrompterOne.Core.Services.Rsvp; using PrompterOne.Core.Services.Streaming; using PrompterOne.Core.Services.Workspace; +using PrompterOne.Shared.Localization; using PrompterOne.Shared.Services; using PrompterOne.Shared.Services.Diagnostics; using PrompterOne.Shared.Services.Editor; @@ -82,6 +84,7 @@ public static AppHarness Create( } context.Services.AddLocalization(); + context.Services.AddSingleton(jsRuntime); context.Services.AddSingleton(jsRuntime); context.Services.AddSingleton(loggerFactory); context.Services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)); @@ -111,6 +114,7 @@ public static AppHarness Create( context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); + context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); context.Services.AddSingleton(); @@ -178,6 +182,8 @@ internal sealed record JsInvocationRecord( internal sealed class TestJsRuntime(TimeSpan? invocationDelay = null) : IJSRuntime { + private const string BrowserCultureGetLanguagesIdentifier = BrowserCultureInteropMethodNames.GetBrowserLanguages; + private const string BrowserCultureSetDocumentLanguageIdentifier = BrowserCultureInteropMethodNames.SetDocumentLanguage; private const string CrossTabDisposeIdentifier = "PrompterOneCrossTabInterop.dispose"; private const string CrossTabInitializeIdentifier = "PrompterOneCrossTabInterop.initialize"; private const string CrossTabPublishIdentifier = "PrompterOneCrossTabInterop.publish"; @@ -210,6 +216,8 @@ internal sealed class TestJsRuntime(TimeSpan? invocationDelay = null) : IJSRunti }; private readonly TimeSpan _invocationDelay = invocationDelay ?? TimeSpan.Zero; + public IReadOnlyList BrowserLanguages { get; private set; } = [AppCultureCatalog.EnglishCultureName]; + public string DocumentLanguage { get; private set; } = AppCultureCatalog.DefaultCultureName; public Dictionary SavedValues { get; } = new(StringComparer.Ordinal); public Dictionary SavedJsonValues { get; } = new(StringComparer.Ordinal); public List Invocations { get; } = []; @@ -217,6 +225,13 @@ internal sealed class TestJsRuntime(TimeSpan? invocationDelay = null) : IJSRunti private Dictionary GoLiveSessions { get; } = new(StringComparer.Ordinal); private Dictionary GoLiveRemoteSessions { get; } = new(StringComparer.Ordinal); + public void SetBrowserLanguages(params string[] languages) + { + BrowserLanguages = languages + .Where(language => !string.IsNullOrWhiteSpace(language)) + .ToArray(); + } + public void SeedRemoteSources( string sessionId, params (string ConnectionId, string SourceId, string Label, StreamingPlatformKind PlatformKind, string RoomName, string ServerUrl, bool IsConnected)[] sources) @@ -283,6 +298,8 @@ private TValue GetResult(string identifier, object?[]? args) var result = identifier switch { + BrowserCultureGetLanguagesIdentifier => BrowserLanguages.ToArray(), + BrowserCultureSetDocumentLanguageIdentifier => SetDocumentLanguage(args), CrossTabDisposeIdentifier => null, CrossTabInitializeIdentifier => true, CrossTabPublishIdentifier => null, @@ -306,6 +323,12 @@ private TValue GetResult(string identifier, object?[]? args) return (TValue)result; } + private object? SetDocumentLanguage(object?[]? args) + { + DocumentLanguage = args?.FirstOrDefault()?.ToString() ?? AppCultureCatalog.DefaultCultureName; + return null; + } + private bool TryHandleGoLiveOutputInvocation(string identifier, object?[]? args, out TValue result) { result = default!; diff --git a/tests/PrompterOne.App.UITests/Editor/EditorLayoutTests.cs b/tests/PrompterOne.App.UITests/Editor/EditorLayoutTests.cs index 61e97f7..cafcf3b 100644 --- a/tests/PrompterOne.App.UITests/Editor/EditorLayoutTests.cs +++ b/tests/PrompterOne.App.UITests/Editor/EditorLayoutTests.cs @@ -20,8 +20,10 @@ public async Task EditorScreen_MetadataRailStaysDockedToRightOfMainPanel() var mainPanel = page.GetByTestId(UiTestIds.Editor.MainPanel); var metadataRail = page.GetByTestId(UiTestIds.Editor.MetadataRail); - await Expect(mainPanel).ToBeVisibleAsync(); - await Expect(metadataRail).ToBeVisibleAsync(); + await Expect(mainPanel) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.DefaultVisibleTimeoutMs }); + await Expect(metadataRail) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.DefaultVisibleTimeoutMs }); var mainBounds = await GetRequiredBoundingBoxAsync(mainPanel); var railBounds = await GetRequiredBoundingBoxAsync(metadataRail); @@ -59,8 +61,10 @@ public async Task EditorScreen_SourceEditorUsesSingleVerticalScrollSurface() var sourceInput = page.GetByTestId(UiTestIds.Editor.SourceInput); var sourceScrollHost = page.GetByTestId(UiTestIds.Editor.SourceScrollHost); - await Expect(sourceInput).ToBeVisibleAsync(); - await Expect(sourceScrollHost).ToBeVisibleAsync(); + await Expect(sourceInput) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.DefaultVisibleTimeoutMs }); + await Expect(sourceScrollHost) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.DefaultVisibleTimeoutMs }); await sourceInput.EvaluateAsync( """ diff --git a/tests/PrompterOne.App.UITests/Editor/EditorOverlayInteractionTests.cs b/tests/PrompterOne.App.UITests/Editor/EditorOverlayInteractionTests.cs index 0754d8d..0eb44d0 100644 --- a/tests/PrompterOne.App.UITests/Editor/EditorOverlayInteractionTests.cs +++ b/tests/PrompterOne.App.UITests/Editor/EditorOverlayInteractionTests.cs @@ -20,7 +20,8 @@ public async Task EditorScreen_HidesFloatingBarWhileToolbarDropdownIsOpen() var floatingBar = page.GetByTestId(UiTestIds.Editor.FloatingBar); var colorMenu = page.GetByTestId(UiTestIds.Editor.MenuColor); - await Expect(sourceInput).ToBeVisibleAsync(); + await Expect(sourceInput) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.DefaultVisibleTimeoutMs }); await sourceInput.EvaluateAsync( "(element, target) => { const start = element.value.indexOf(target); element.focus(); element.setSelectionRange(start, start + target.length); element.dispatchEvent(new Event('select', { bubbles: true })); element.dispatchEvent(new Event('keyup', { bubbles: true })); }", BrowserTestConstants.Editor.Welcome); diff --git a/tests/PrompterOne.App.UITests/Learn/LearnStartupAlignmentTests.cs b/tests/PrompterOne.App.UITests/Learn/LearnStartupAlignmentTests.cs index b82038a..bcbc089 100644 --- a/tests/PrompterOne.App.UITests/Learn/LearnStartupAlignmentTests.cs +++ b/tests/PrompterOne.App.UITests/Learn/LearnStartupAlignmentTests.cs @@ -68,6 +68,7 @@ private static string BuildStartupTraceScript() const learnLineTestId = {{ToJsString(UiTestIds.Learn.OrpLine)}}; const learnWordTestId = {{ToJsString(UiTestIds.Learn.Word)}}; const maxSamples = 16; + const maxAnimationFramePasses = maxSamples; window.__learnStartupTrace = []; @@ -98,6 +99,18 @@ private static string BuildStartupTraceScript() }); }; + let animationFramePasses = 0; + const captureOnAnimationFrame = () => { + capture(); + + if (window.__learnStartupTrace.length >= maxSamples || animationFramePasses >= maxAnimationFramePasses) { + return; + } + + animationFramePasses += 1; + window.requestAnimationFrame(captureOnAnimationFrame); + }; + new MutationObserver(capture).observe(document, { attributeFilter: ['style', layoutReadyAttributeName], attributes: true, @@ -105,6 +118,9 @@ private static string BuildStartupTraceScript() childList: true, subtree: true }); + + capture(); + window.requestAnimationFrame(captureOnAnimationFrame); })(); """; } diff --git a/tests/PrompterOne.App.UITests/Localization/LocalizationFlowTests.cs b/tests/PrompterOne.App.UITests/Localization/LocalizationFlowTests.cs index be45ae7..3370aad 100644 --- a/tests/PrompterOne.App.UITests/Localization/LocalizationFlowTests.cs +++ b/tests/PrompterOne.App.UITests/Localization/LocalizationFlowTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Playwright; using PrompterOne.Shared.Contracts; using static Microsoft.Playwright.Assertions; @@ -8,17 +9,41 @@ public sealed class LocalizationFlowTests(StandaloneAppFixture fixture) : IClass private readonly StandaloneAppFixture _fixture = fixture; [Fact] - public async Task LibraryScreen_UsesStoredFrenchCulture_ForLocalizedChrome() + public async Task LibraryScreen_UsesBrowserGermanCulture_ForLocalizedChrome() { var page = await _fixture.NewPageAsync(); try { + await page.AddInitScriptAsync(BrowserTestConstants.Localization.BuildNavigatorLanguagesInitScript("de-DE", "en-US")); + await page.GotoAsync(BrowserTestConstants.Routes.Library); + + await Expect(page.GetByTestId(UiTestIds.Library.SortLabel)) + .ToHaveTextAsync(BrowserTestConstants.Localization.GermanSortByLabel); + } + finally + { + await page.Context.CloseAsync(); + } + } + + [Fact] + public async Task SettingsLanguageSelection_PersistsFrenchCulture_AfterReload() + { + var page = await _fixture.NewPageAsync(); + + try + { + await page.GotoAsync(BrowserTestConstants.Routes.Settings); + await page.GetByTestId(UiTestIds.Settings.NavAppearance).ClickAsync(); + await page.GetByTestId(UiTestIds.Settings.LanguageSelect).ClickAsync(); + await page.GetByTestId(UiTestIds.Settings.SelectOption(UiTestIds.Settings.LanguageSelect, BrowserTestConstants.Localization.FrenchCultureName)).ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded); + + await Expect(page.GetByTestId(UiTestIds.Settings.LanguageSelect)) + .ToContainTextAsync(BrowserTestConstants.Localization.FrenchLanguageLabel); + await page.GotoAsync(BrowserTestConstants.Routes.Library); - await page.EvaluateAsync( - BrowserTestConstants.Localization.SetLocalStorageScript, - new[] { BrowserTestConstants.Localization.CultureStorageKey, BrowserTestConstants.Localization.FrenchCultureName }); - await page.ReloadAsync(); await Expect(page.GetByTestId(UiTestIds.Library.SortLabel)) .ToHaveTextAsync(BrowserTestConstants.Localization.FrenchSortByLabel); @@ -34,4 +59,23 @@ await Expect(page.GetByTestId(UiTestIds.Library.NewFolderTitle)) await page.Context.CloseAsync(); } } + + [Fact] + public async Task LibraryScreen_FallsBackToEnglish_WhenBrowserCultureIsRussian() + { + var page = await _fixture.NewPageAsync(); + + try + { + await page.AddInitScriptAsync(BrowserTestConstants.Localization.BuildNavigatorLanguagesInitScript("ru-RU", "uk-UA")); + await page.GotoAsync(BrowserTestConstants.Routes.Library); + + await Expect(page.GetByTestId(UiTestIds.Library.SortLabel)) + .ToHaveTextAsync(BrowserTestConstants.Localization.EnglishSortByLabel); + } + finally + { + await page.Context.CloseAsync(); + } + } } diff --git a/tests/PrompterOne.App.UITests/Media/recording-file-harness.js b/tests/PrompterOne.App.UITests/Media/recording-file-harness.js index c874fa8..17386f8 100644 --- a/tests/PrompterOne.App.UITests/Media/recording-file-harness.js +++ b/tests/PrompterOne.App.UITests/Media/recording-file-harness.js @@ -6,6 +6,8 @@ const minimumAudibleFrequencyValue = 8; const minimumVisibleChannelValue = 12; const minimumVisiblePixelCount = 16; + const visibleVideoProbeTimeoutMs = 1500; + const visibleVideoPollDelayMs = 100; const readyStateHaveCurrentData = 2; if (typeof window[harnessGlobalName] === "object" && window[harnessGlobalName] !== null) { @@ -115,6 +117,36 @@ }; } + async function waitForNextVideoFrame(videoElement) { + if (typeof videoElement.requestVideoFrameCallback === "function") { + await new Promise(resolve => videoElement.requestVideoFrameCallback(() => resolve())); + return; + } + + await new Promise(resolve => window.setTimeout(resolve, visibleVideoPollDelayMs)); + } + + async function detectVisibleVideoAcrossFrames(videoElement) { + const deadline = Date.now() + visibleVideoProbeTimeoutMs; + let highestVisiblePixelCount = 0; + + while (Date.now() <= deadline) { + const sample = detectVisibleVideo(videoElement); + highestVisiblePixelCount = Math.max(highestVisiblePixelCount, sample.nonBlackPixelCount); + + if (sample.hasVisibleVideo) { + return sample; + } + + await waitForNextVideoFrame(videoElement); + } + + return { + hasVisibleVideo: highestVisiblePixelCount >= minimumVisiblePixelCount, + nonBlackPixelCount: highestVisiblePixelCount + }; + } + async function analyzeSavedRecording() { if (!(savedBlob instanceof Blob)) { return null; @@ -139,7 +171,7 @@ : null; const hasAudioTrack = Boolean(captureStream?.getAudioTracks?.().length); const hasAudibleAudio = await detectAudibleAudio(captureStream); - const visibleVideo = detectVisibleVideo(videoElement); + const visibleVideo = await detectVisibleVideoAcrossFrames(videoElement); captureStream?.getTracks?.().forEach(track => track.stop()); diff --git a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs index 3b576ad..65f28d8 100644 --- a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs +++ b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs @@ -716,11 +716,37 @@ public static class Diagnostics public static class Localization { public const string CultureStorageKey = "prompterone.settings.culture"; + public const string EnglishSortByLabel = "Sort by"; public const string FrenchCultureName = "fr"; public const string FrenchCreateFolderTitle = "Créer un dossier"; + public const string FrenchLanguageLabel = "Français"; public const string FrenchFoldersLabel = "Dossiers"; public const string FrenchSortByLabel = "Trier par"; + public const string GermanCultureName = "de"; + public const string GermanSortByLabel = "Sortieren nach"; + public const string RussianCultureName = "ru"; public const string SetLocalStorageScript = "([key, value]) => window.localStorage.setItem(key, value)"; + + public static string BuildNavigatorLanguagesInitScript(params string[] languages) + { + var serializedLanguages = string.Join( + ", ", + languages.Select(language => $"'{language}'")); + + return $$""" + (() => { + const languages = [{{serializedLanguages}}]; + Object.defineProperty(window.navigator, 'languages', { + configurable: true, + get: () => languages + }); + Object.defineProperty(window.navigator, 'language', { + configurable: true, + get: () => languages[0] + }); + })(); + """; + } } public static class Timing diff --git a/tests/PrompterOne.Core.Tests/Localization/AppCultureCatalogTests.cs b/tests/PrompterOne.Core.Tests/Localization/AppCultureCatalogTests.cs index 162ff90..512e334 100644 --- a/tests/PrompterOne.Core.Tests/Localization/AppCultureCatalogTests.cs +++ b/tests/PrompterOne.Core.Tests/Localization/AppCultureCatalogTests.cs @@ -8,8 +8,8 @@ public sealed class AppCultureCatalogTests [InlineData("fr-FR", AppCultureCatalog.FrenchCultureName)] [InlineData("uk-UA", AppCultureCatalog.UkrainianCultureName)] [InlineData("pt-BR", AppCultureCatalog.PortugueseCultureName)] + [InlineData("de-DE", AppCultureCatalog.GermanCultureName)] [InlineData("ru-RU", AppCultureCatalog.EnglishCultureName)] - [InlineData("de-DE", AppCultureCatalog.EnglishCultureName)] [InlineData("", AppCultureCatalog.EnglishCultureName)] public void ResolveSupportedCulture_NormalizesBrowserCultureNames(string requestedCulture, string expectedCulture) { @@ -25,4 +25,13 @@ public void ResolvePreferredCulture_UsesFirstSupportedCulture_AndBlocksRussian() Assert.Equal(AppCultureCatalog.EnglishCultureName, actualCulture); } + + [Fact] + public void SupportedCultureDefinitionsInDisplayOrder_ContainsGerman() + { + var german = AppCultureCatalog.SupportedCultureDefinitionsInDisplayOrder + .Single(culture => string.Equals(culture.CultureName, AppCultureCatalog.GermanCultureName, StringComparison.Ordinal)); + + Assert.Equal("Deutsch", german.DisplayName); + } }