From a03bfa4be501e524c680983d0e3d0b4e3414c54d Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Wed, 1 Apr 2026 21:57:17 +0200 Subject: [PATCH 1/5] fixe --- AGENTS.md | 12 ++ .../Tps/Services/ScriptCompiler.cs | 20 +++ .../Tps/Services/TpsTokenTextRules.cs | 46 ++++++ .../Models/ReaderSettingsDefaults.cs | 2 +- src/PrompterOne.Shared/Contracts/UiTestIds.cs | 1 + .../GoLive/Components/GoLiveSourcesCard.razor | 103 ------------- .../Components/GoLiveSourcesCard.razor.css | 1 + .../GoLive/Pages/GoLivePage.StudioSurface.cs | 32 ++-- .../GoLive/Pages/GoLivePage.razor | 9 +- .../GoLive/Pages/GoLivePage.razor.cs | 5 +- .../Learn/Pages/LearnPage.razor | 24 +-- .../Services/BrowserMediaDeviceService.cs | 20 ++- .../Components/SettingsAiSection.razor | 31 ++-- .../SettingsAppearanceSection.razor | 20 ++- .../Components/SettingsCloudSection.razor | 35 ++--- .../Components/SettingsCloudSection.razor.cs | 40 ++++- .../Components/SettingsFilesSection.razor | 41 +++--- .../Components/SettingsRecordingSection.razor | 138 +++++++++++------- .../Settings/Components/SettingsSelect.razor | 91 ++++++++++++ .../Components/SettingsSelect.razor.css | 89 +++++++++++ .../Components/SettingsSelectOption.cs | 3 + .../Components/SettingsStreamingPanel.razor | 43 +++--- .../Settings/Pages/SettingsPage.razor | 23 +-- .../Pages/TeleprompterPage.ReaderAlignment.cs | 37 ++++- .../Pages/TeleprompterPage.ReaderContent.cs | 32 ++-- .../Pages/TeleprompterPage.ReaderModels.cs | 18 +-- .../Pages/TeleprompterPage.ReaderPlayback.cs | 30 ++-- .../Pages/TeleprompterPage.ReaderRendering.cs | 11 +- .../TeleprompterPage.ReaderWordStyling.cs | 16 +- .../Pages/TeleprompterPage.razor.cs | 3 +- .../wwwroot/design/modules/30-rsvp.css | 43 ++++-- .../design/modules/reader/00-shell.css | 10 +- .../modules/reader/10-reading-states.css | 24 ++- .../design/modules/settings/20-reference.css | 4 +- .../wwwroot/learn/learn-rsvp-layout.js | 17 ++- .../GoLive/GoLivePageTests.cs | 17 +++ .../Teleprompter/TeleprompterFidelityTests.cs | 62 ++++++++ .../GoLive/GoLiveFlowTests.cs | 8 +- .../Learn/LearnWordLaneStabilityTests.cs | 49 ++++--- .../BrowserTestConstants.ScreenFlows.cs | 2 +- .../Teleprompter/TeleprompterFidelityTests.cs | 82 +++++++++++ .../TeleprompterSettingsFlowTests.cs | 2 +- .../Tps/TpsRoundTripTests.cs | 33 +++++ 43 files changed, 930 insertions(+), 399 deletions(-) create mode 100644 src/PrompterOne.Core/Tps/Services/TpsTokenTextRules.cs create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsSelect.razor create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsSelect.razor.css create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsSelectOption.cs diff --git a/AGENTS.md b/AGENTS.md index 6d5b60c..76c09fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -304,8 +304,14 @@ Repo-specific design rules: - Repo-owned manifests, scripts, workflows, and project files that track third-party runtime JavaScript SDKs MUST point to concrete GitHub release versions and asset URLs, never floating references. - Any vendored runtime JavaScript SDK that tracks an upstream GitHub repo MUST have an automated watcher job that checks new GitHub releases and opens a repo issue describing the required update when a newer release appears. - Teleprompter TPS speed modifiers MUST affect both playback timing and subtle word- or phrase-level letter spacing, so slower spans open up slightly and faster spans tighten slightly without hurting readability. +- Teleprompter default reader width MUST start at the maximum readable width from the design unless the user explicitly narrows it; shipping a visibly narrower default is a regression. +- Teleprompter speed styling MUST produce a visible but tasteful letter-spacing or kerning change: slower text opens up slightly and faster text tightens slightly, not a no-op. - Teleprompter reader word styling MUST mirror TPS/editor inline semantics: explicit inline TPS tags control per-word emphasis and color, while section or block emotion sets card context and must not recolor every reader word. +- Teleprompter underline or highlight treatments that span a phrase or block MUST render as one continuous block-level treatment; separate per-word underlines inside the same phrase are forbidden. +- Teleprompter reader text MUST appear on the focal guide immediately when a word or block becomes active; visible post-appearance drift or settling onto the guide is forbidden. - Teleprompter block transitions MUST stay visually consistent: outgoing cards move upward and incoming cards rise from below in the same direction every time; alternating up/down travel is forbidden, and extra settling, bounce, or intermediate card states are forbidden. +- Reader and Learn tokenization MUST treat punctuation-only tokens such as commas, periods, and dashes as punctuation attached to nearby words or pauses, never as standalone counted words. +- App-shell logo navigation MUST always lead to the main home/library screen; it must not deep-link into Go Live, Teleprompter, or another feature-specific route. - Learn and Teleprompter are separate screens with separate style ownership; do not bundle RSVP and teleprompter reader feature styles into one shared screen stylesheet or let one page inherit the other page's visual treatment. - User preferences persistence MUST sit behind a platform-agnostic user-settings abstraction, with browser storage implemented via local storage and room for other platform-specific implementations; theme, teleprompter layout preferences, camera/scene preferences, and similar saved settings belong there instead of ad-hoc feature stores. - Build quality gates must stay green under `-warnaserror`. @@ -368,8 +374,14 @@ Ask first: - made-up About/team content or stale attribution; About must point to real Managed Code ownership and official links - any visible typing latency in the editor; plain input must feel immediate with no observable delay - teleprompter controls that fade so much they become hard to see during real reading +- teleprompter starting with a narrowed text width instead of the design-max default - teleprompter paragraph repositioning, line hopping, or per-word vertical transform updates that make the text jump; `design/teleprompter.html` motion is the required reference, with steady bottom-to-top movement and no extra animation layers beyond the reference +- teleprompter words or blocks appearing away from the focus line and only then drifting onto it; activation must look immediate +- teleprompter section changes that introduce odd transition motion instead of the straight reference direction - any green teleprompter shell or background treatment; Teleprompter must stay on its dark reader palette and use emotion only for accents, not green screen-wide fills +- fragmented per-word underline styling where the intended emphasis should read as one continuous block +- punctuation showing up or being counted as standalone words in Learn or Teleprompter flows +- app logo clicks landing on a feature route instead of the main home/library screen - Learn and Teleprompter style boundaries bleeding through a shared feature stylesheet; their visuals must stay isolated by page-owned style manifests - Learn RSVP compositions that shift when shorter or longer words render; changing word length must not move the overall RSVP component or its anchored centerline - teleprompter camera starting enabled by default; default reader startup should keep the camera off until the user explicitly enables it diff --git a/src/PrompterOne.Core/Tps/Services/ScriptCompiler.cs b/src/PrompterOne.Core/Tps/Services/ScriptCompiler.cs index ece4ce6..3147635 100644 --- a/src/PrompterOne.Core/Tps/Services/ScriptCompiler.cs +++ b/src/PrompterOne.Core/Tps/Services/ScriptCompiler.cs @@ -669,6 +669,12 @@ private static void AddWord(List words, string token, FormattingSt return; } + if (TpsTokenTextRules.IsStandalonePunctuationToken(clean)) + { + AttachStandalonePunctuation(words, clean); + return; + } + var metadata = new WordMetadata { IsEmphasis = state.IsEmphasis, @@ -708,6 +714,20 @@ private static void AddWord(List words, string token, FormattingSt }); } + private static void AttachStandalonePunctuation(List words, string punctuationToken) + { + var previousWord = words.LastOrDefault(word => + word.Metadata?.IsPause != true && + !string.IsNullOrWhiteSpace(word.CleanText)); + if (previousWord is null) + { + return; + } + + previousWord.CleanText += TpsTokenTextRules.BuildStandalonePunctuationSuffix(punctuationToken); + previousWord.CharacterCount = previousWord.CleanText.Length; + } + private static string NormalizeContent(string text) { if (string.IsNullOrEmpty(text)) diff --git a/src/PrompterOne.Core/Tps/Services/TpsTokenTextRules.cs b/src/PrompterOne.Core/Tps/Services/TpsTokenTextRules.cs new file mode 100644 index 0000000..ddf4bcf --- /dev/null +++ b/src/PrompterOne.Core/Tps/Services/TpsTokenTextRules.cs @@ -0,0 +1,46 @@ +namespace PrompterOne.Core.Services; + +internal static class TpsTokenTextRules +{ + private const string StandaloneDashCharacters = "-—–"; + private const string StandalonePunctuationCharacters = ",.;:!?-—–…"; + + public static bool IsStandalonePunctuationToken(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + foreach (var character in token.Trim()) + { + if (!StandalonePunctuationCharacters.Contains(character)) + { + return false; + } + } + + return true; + } + + public static string BuildStandalonePunctuationSuffix(string token) + { + var trimmed = token.Trim(); + return UsesLeadingSeparator(trimmed) + ? string.Concat(" ", trimmed) + : trimmed; + } + + private static bool UsesLeadingSeparator(string token) + { + foreach (var character in token) + { + if (!StandaloneDashCharacters.Contains(character)) + { + return false; + } + } + + return token.Length > 0; + } +} diff --git a/src/PrompterOne.Core/Workspace/Models/ReaderSettingsDefaults.cs b/src/PrompterOne.Core/Workspace/Models/ReaderSettingsDefaults.cs index d06d329..8f0a0bc 100644 --- a/src/PrompterOne.Core/Workspace/Models/ReaderSettingsDefaults.cs +++ b/src/PrompterOne.Core/Workspace/Models/ReaderSettingsDefaults.cs @@ -4,7 +4,7 @@ public static class ReaderSettingsDefaults { public const int CountdownSeconds = 3; public const double FontScale = 1.0; - public const double TextWidth = 0.72; + public const double TextWidth = 1.0; public const double ScrollSpeed = 1.0; public const bool MirrorText = false; public const bool ShowFocusLine = true; diff --git a/src/PrompterOne.Shared/Contracts/UiTestIds.cs b/src/PrompterOne.Shared/Contracts/UiTestIds.cs index c608095..45722c2 100644 --- a/src/PrompterOne.Shared/Contracts/UiTestIds.cs +++ b/src/PrompterOne.Shared/Contracts/UiTestIds.cs @@ -358,6 +358,7 @@ public static class GoLive public const string ModeStudio = "go-live-mode-studio"; public const string NdiToggle = "go-live-ndi-toggle"; public const string ObsToggle = "go-live-obs-toggle"; + public const string OpenHome = "go-live-open-home"; public const string OpenLearn = "go-live-open-learn"; public const string OpenRead = "go-live-open-read"; public const string OpenSettings = "go-live-open-settings"; diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor index 9aa874e..0bb17df 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor @@ -1,5 +1,4 @@ @namespace PrompterOne.Shared.Components.GoLive -@using PrompterOne.Core.Models.Workspace
@@ -52,37 +51,6 @@ @source.Label @GetSourceStatus(source)
- -
-
-
@GetSourceAvatarInitial(source)
- @GetSourceReaderLabel(source) -
- -
- @foreach (var crop in CropOptions) - { - var cropIsActive = GetCropSelection(source.SourceId) == crop; - - } -
-
- -
- - @CurrentScriptTitle - @CurrentScriptProgressLabel -
@@ -128,25 +96,14 @@ private const string PrompterUtilitySourceId = "prompter-display"; private const string RemoveLabel = "Remove"; private const string ScreenShareUtilitySourceId = "screen-share"; - private const string ScriptProgressDefaultLabel = "No script loaded"; private const string SourceIncludedLabel = "Armed for output"; private const string SourceLiveLabel = "Ready for canvas"; private const string SourceOnAirLabel = "On air"; private const string SourceSceneOnlyLabel = "Scene only"; - private static readonly IReadOnlyList CropOptions = - [ - GoLiveCropPreset.Full, - GoLiveCropPreset.HeadAndShoulders, - GoLiveCropPreset.Face - ]; - - private readonly Dictionary _cropSelections = new(StringComparer.Ordinal); [Parameter] public EventCallback AddCamera { get; set; } [Parameter] public string? ActiveSourceId { get; set; } [Parameter] public bool CanAddCamera { get; set; } - [Parameter] public string CurrentScriptProgressLabel { get; set; } = ScriptProgressDefaultLabel; - [Parameter] public string CurrentScriptTitle { get; set; } = ScriptWorkspaceState.UntitledScriptTitle; [Parameter] public bool HasPrimaryMicrophone { get; set; } [Parameter] public string MicrophoneName { get; set; } = string.Empty; [Parameter] public string MicrophoneRouteLabel { get; set; } = string.Empty; @@ -175,49 +132,6 @@ : SourceSceneOnlyLabel; } - private static string GetCropLabel(GoLiveCropPreset crop) => - crop switch - { - GoLiveCropPreset.HeadAndShoulders => "H&S", - GoLiveCropPreset.Face => "Face", - _ => "Full" - }; - - private static string GetSourceAvatarInitial(SceneCameraSource source) - { - return string.IsNullOrWhiteSpace(source.Label) - ? "C" - : source.Label[..1].ToUpperInvariant(); - } - - private string GetSourceAvatarCssClass(SceneCameraSource source) - { - var classes = new List { "gl-cam-avatar" }; - classes.Add(IsActive(source) - ? "gl-cam-avatar-live" - : IsSelected(source) - ? "gl-cam-avatar-preview" - : "gl-cam-avatar-idle"); - return string.Join(' ', classes); - } - - private string GetSourceReaderLabel(SceneCameraSource source) - { - if (IsActive(source)) - { - return SourceOnAirLabel; - } - - if (IsSelected(source)) - { - return SourceLiveLabel; - } - - return source.Transform.IncludeInOutput - ? SourceIncludedLabel - : SourceSceneOnlyLabel; - } - private string GetSourceBadge(SceneCameraSource source) { if (IsActive(source)) @@ -232,23 +146,6 @@ : SourceSceneOnlyLabel; } - private string GetSourceScriptTagCssClass(SceneCameraSource source) - { - return IsActive(source) || IsSelected(source) - ? "gl-script-active" - : string.Empty; - } - - private GoLiveCropPreset GetCropSelection(string sourceId) - { - return _cropSelections.TryGetValue(sourceId, out var crop) ? crop : GoLiveCropPreset.Full; - } - - private void SetCropSelection(string sourceId, GoLiveCropPreset crop) - { - _cropSelections[sourceId] = crop; - } - private string GetSourceCardCssClass(SceneCameraSource source) { var classes = new List { "gl-cam-card" }; diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor.css b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor.css index 0afbbff..24e92a2 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor.css +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor.css @@ -42,6 +42,7 @@ display: flex; flex-direction: column; gap: 8px; + flex: 1 1 0; min-height: 0; overflow-y: auto; } diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs index c4f4fcf..78aeefb 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs @@ -40,8 +40,6 @@ public partial class GoLivePage private const string RecordingReadyDetailLabel = "Ready"; private const string RecordingReadyMetricValue = "Armed"; private const string RelayPlatformLabel = "Relay preset"; - private const string RemoteTalentSourceId = "prompter-display"; - private const string RemoteTalentTitle = "Prompter Display"; private const string RoomCodeFallback = "local-studio"; private const string RuntimeEngineIdleValue = "Idle"; private const string RuntimeEngineLabel = "Runtime"; @@ -51,32 +49,18 @@ public partial class GoLivePage private const string RuntimeEngineRecorderValue = "Recorder"; private const string SceneSlidesId = "scene-slides"; private const string SceneSlidesLabel = "Slides"; - private const string ScreenShareSourceId = "screen-share"; - private const string ScreenShareTitle = "Share Screen"; private const string SecondarySceneId = "scene-secondary"; private const string SettingsPlatformLabel = "Settings preset"; private const string StatusBitrateLabel = "Bitrate"; private const string StatusOutputLabel = "Output"; + private const string StudioSourcesTitle = "Sources"; private const string SessionMetricLabel = "Session"; - private const string SlidesSourceId = "slides"; - private const string SlidesSourceTitle = "Slides"; - private const string UtilitySourceClickLabel = "Click to share"; - private const string UtilitySourcePrompterBadge = "Prompter"; - private const string UtilitySourceShareBadge = "Add"; - private const string UtilitySourceSlidesBadge = "Slides"; - private const string UtilitySourceSlidesLabel = "Keynote"; - private const string UtilitySourceTalentFacingLabel = "Talent-facing only"; + private const string DirectorSourcesTitle = "Cameras"; private const string GoLiveContentBaseClass = "gl-content"; private const string GoLiveFullProgramClass = "gl-layout-fullpgm"; private const string GoLiveHideLeftClass = "gl-hide-left"; private const string GoLiveHideRightClass = "gl-hide-right"; - private static readonly IReadOnlyList StudioUtilitySources = - [ - new(RemoteTalentSourceId, RemoteTalentTitle, UtilitySourceTalentFacingLabel, UtilitySourcePrompterBadge), - new(SlidesSourceId, SlidesSourceTitle, UtilitySourceSlidesLabel, UtilitySourceSlidesBadge), - new(ScreenShareSourceId, ScreenShareTitle, UtilitySourceClickLabel, UtilitySourceShareBadge) - ]; private string _activeSceneId = PrimarySceneId; private GoLiveSceneLayout _activeSceneLayout = GoLiveSceneLayout.Full; @@ -113,7 +97,17 @@ public partial class GoLivePage private IReadOnlyList StatusMetrics => BuildStatusMetrics(); - private static IReadOnlyList UtilitySources => StudioUtilitySources; + private static IReadOnlyList UtilitySources => []; + + private IReadOnlyList VisibleSceneCameras => + _activeStudioMode == GoLiveStudioMode.Studio && SceneCameras.Count > 0 + ? [SceneCameras[0]] + : SceneCameras; + + private string SourcesHeaderTitle => + _activeStudioMode == GoLiveStudioMode.Director + ? DirectorSourcesTitle + : StudioSourcesTitle; private string GoLiveContentClass { diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor index f1b0ba2..aaa85da 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor @@ -11,8 +11,8 @@ data-testid="@UiTestIds.GoLive.SessionBar">
-
- @foreach (var word in _leftContextWords) - { - @word - } +
+
+ @foreach (var word in _leftContextWords) + { + @word + } +
-
- @foreach (var word in _rightContextWords) - { - @word - } +
+
+ @foreach (var word in _rightContextWords) + { + @word + } +
diff --git a/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs b/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs index 25fdd35..0fa4078 100644 --- a/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs +++ b/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs @@ -1,11 +1,13 @@ +using System.Text.RegularExpressions; using Microsoft.JSInterop; using PrompterOne.Core.Abstractions; using PrompterOne.Core.Models.Media; namespace PrompterOne.Shared.Services; -public sealed class BrowserMediaDeviceService(IJSRuntime jsRuntime) : IMediaDeviceService +public sealed partial class BrowserMediaDeviceService(IJSRuntime jsRuntime) : IMediaDeviceService { + private const string UnnamedDeviceLabel = "Unnamed device"; private readonly IJSRuntime _jsRuntime = jsRuntime; public async Task> GetDevicesAsync(CancellationToken cancellationToken = default) @@ -16,7 +18,7 @@ public async Task> GetDevicesAsync(CancellationTo return devices.Select(device => new MediaDeviceInfo( device.DeviceId, - string.IsNullOrWhiteSpace(device.Label) ? "Unnamed device" : device.Label, + SanitizeLabel(device.Label), device.Kind switch { "videoinput" => MediaDeviceKind.Camera, @@ -27,5 +29,19 @@ public async Task> GetDevicesAsync(CancellationTo device.IsDefault)).ToList(); } + private static string SanitizeLabel(string? rawLabel) + { + if (string.IsNullOrWhiteSpace(rawLabel)) + { + return UnnamedDeviceLabel; + } + + var cleaned = VendorIdPattern().Replace(rawLabel, string.Empty).Trim(); + return string.IsNullOrWhiteSpace(cleaned) ? UnnamedDeviceLabel : cleaned; + } + + [GeneratedRegex(@"\s*\([0-9a-fA-F]{4}:[0-9a-fA-F]{4}\)")] + private static partial Regex VendorIdPattern(); + private sealed record BrowserMediaDeviceDto(string DeviceId, string Label, string Kind, bool IsDefault); } diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor index 5738fe4..99eb376 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor @@ -1,5 +1,6 @@ @namespace PrompterOne.Shared.Components.Settings @using PrompterOne.Shared.Pages +@using PrompterOne.Shared.Settings.Components
- +
@@ -79,12 +77,8 @@
- +
+ + @if (_open) + { +
+ @foreach (var option in Options) + { + var isSelected = string.Equals(option.Value, Value, StringComparison.Ordinal); + + } +
+ } +
+ +@code { + private const string OpenChevronClass = "ss-chevron-open"; + private const string SelectedOptionClass = "ss-option-selected"; + private const string FallbackLabel = "—"; + + private bool _open; + + [Parameter] public string Value { get; set; } = string.Empty; + [Parameter] public IReadOnlyList Options { get; set; } = []; + [Parameter] public EventCallback OnChange { get; set; } + [Parameter] public string? TestId { get; set; } + + private string SelectedLabel => + Options.FirstOrDefault(o => string.Equals(o.Value, Value, StringComparison.Ordinal))?.Label + ?? FallbackLabel; + + private void Toggle() + { + _open = !_open; + } + + private async Task SelectOption(string value) + { + _open = false; + if (!string.Equals(value, Value, StringComparison.Ordinal)) + { + await OnChange.InvokeAsync(new ChangeEventArgs { Value = value }); + } + } + + private void OnFocusOut() + { + _open = false; + } +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsSelect.razor.css b/src/PrompterOne.Shared/Settings/Components/SettingsSelect.razor.css new file mode 100644 index 0000000..8436559 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsSelect.razor.css @@ -0,0 +1,89 @@ +.ss-wrap { + position: relative; + outline: none; +} + +.ss-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + text-align: left; +} + +.ss-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-chevron { + flex-shrink: 0; + opacity: .5; + transition: transform .15s; +} + +.ss-chevron-open { + transform: rotate(180deg); +} + +.ss-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 100; + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px; + background: rgba(12, 20, 24, .95); + backdrop-filter: blur(16px); + border: 1px solid var(--gold-14); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, .5); + max-height: 280px; + overflow-y: auto; +} + +.ss-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--t2); + font-size: 13px; + font-weight: 500; + font-family: var(--font); + text-align: left; + cursor: pointer; + transition: background .1s, color .1s; +} + +.ss-option:hover { + background: var(--gold-07); + color: var(--t1); +} + +.ss-option-selected { + color: var(--t1); + font-weight: 600; +} + +.ss-check { + flex-shrink: 0; + color: var(--accent); +} + +.ss-check-spacer { + display: inline-block; + width: 14px; + flex-shrink: 0; +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsSelectOption.cs b/src/PrompterOne.Shared/Settings/Components/SettingsSelectOption.cs new file mode 100644 index 0000000..49791d3 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsSelectOption.cs @@ -0,0 +1,3 @@ +namespace PrompterOne.Shared.Settings.Components; + +public sealed record SettingsSelectOption(string Value, string Label); diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor index 26fb9ce..71db4b0 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor @@ -2,33 +2,24 @@ @using PrompterOne.Core.Models.Media @using PrompterOne.Core.Models.Workspace @using PrompterOne.Core.Services.Streaming +@using PrompterOne.Shared.Settings.Components

Output Defaults

- +
- +
@@ -331,6 +322,22 @@
@code { + private static readonly IReadOnlyList OutputModeOptions = + [ + new(VirtualCameraOutputModeValue, "Virtual Camera"), + new(NdiOutputModeValue, "NDI Output"), + new(DirectRtmpOutputModeValue, "Direct RTMP"), + new(LocalRecordingOutputModeValue, "Local Recording"), + ]; + + private static readonly IReadOnlyList OutputResolutionOptions = + [ + new(nameof(StreamingResolutionPreset.FullHd1080p30), "1920 x 1080 @ 30fps"), + new(nameof(StreamingResolutionPreset.FullHd1080p60), "1920 x 1080 @ 60fps"), + new(nameof(StreamingResolutionPreset.Hd720p30), "1280 x 720 @ 30fps"), + new(nameof(StreamingResolutionPreset.UltraHd2160p30), "3840 x 2160 @ 30fps"), + ]; + private const string CustomRtmpCardId = "streaming-custom-rtmp"; private const string DirectRtmpOutputModeValue = "direct-rtmp"; private const string DisabledSummary = "Disabled until you arm this destination."; diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor index 93555da..39ec5ff 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor @@ -6,6 +6,7 @@ @using PrompterOne.Core.Models.Workspace @using PrompterOne.Shared.Components.Settings @using PrompterOne.Shared.Services.Diagnostics +@using PrompterOne.Shared.Settings.Components @using PrompterOne.Shared.Storage @inject AppBootstrapper Bootstrapper @@ -150,15 +151,10 @@
- +
@@ -549,6 +545,15 @@ 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 = []; diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs index cc28ab4..b927015 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs @@ -9,6 +9,7 @@ public partial class TeleprompterPage private const string ReaderTextNoTransitionStyle = "transition:none;"; private readonly Dictionary _readerCardTextStyles = []; + private readonly HashSet _readerCardsPendingTransitionRestore = []; private readonly HashSet _readerCardsWithoutTransition = []; private bool _pendingReaderAlignment; private bool _pendingReaderAlignmentInstant; @@ -61,6 +62,7 @@ private void RequestReaderAlignment(bool instant = false) private void ResetReaderAlignmentState() { _readerCardTextStyles.Clear(); + _readerCardsPendingTransitionRestore.Clear(); _readerCardsWithoutTransition.Clear(); _pendingReaderAlignment = true; _pendingReaderAlignmentInstant = true; @@ -69,6 +71,31 @@ private void ResetReaderAlignmentState() private Task PrepareReaderCardAlignmentAsync(int cardIndex, int wordOrdinal) => AlignReaderCardTextAsync(cardIndex, wordOrdinal, neutralizeCard: true, rerender: false, instantTransition: false); + private Task AlignReaderWordBeforeActivationAsync(int cardIndex, int wordOrdinal) => + AlignReaderCardTextAsync(cardIndex, wordOrdinal, neutralizeCard: false, rerender: false, instantTransition: true); + + private async Task RestorePendingReaderTextTransitionsAsync() + { + if (_readerCardsPendingTransitionRestore.Count == 0) + { + return; + } + + var cardsToRestore = _readerCardsPendingTransitionRestore.ToArray(); + _readerCardsPendingTransitionRestore.Clear(); + + var changed = false; + foreach (var cardIndex in cardsToRestore) + { + changed |= _readerCardsWithoutTransition.Remove(cardIndex); + } + + if (changed) + { + await InvokeAsync(StateHasChanged); + } + } + private async Task AlignReaderCardTextAsync( int cardIndex, int wordOrdinal, @@ -108,23 +135,17 @@ private async Task AlignReaderCardTextAsync( if (instantTransition) { _readerCardsWithoutTransition.Add(cardIndex); + _readerCardsPendingTransitionRestore.Add(cardIndex); } else { _readerCardsWithoutTransition.Remove(cardIndex); + _readerCardsPendingTransitionRestore.Remove(cardIndex); } if (rerender) { await InvokeAsync(StateHasChanged); - if (instantTransition) - { - await Task.Yield(); - if (_readerCardsWithoutTransition.Remove(cardIndex)) - { - await InvokeAsync(StateHasChanged); - } - } } } diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs index b5433bb..3887b62 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs @@ -161,6 +161,7 @@ private static IReadOnlyList BuildReaderChunks(IEnumerable var chunks = new List(); var currentGroup = new List(); var currentCharacterCount = 0; + bool? currentGroupIsEmphasis = null; foreach (var word in words) { @@ -173,8 +174,9 @@ private static IReadOnlyList BuildReaderChunks(IEnumerable currentGroup[^1] = lastWord with { PauseAfterMs = pauseDuration }; } - FlushGroup(chunks, currentGroup); + FlushGroup(chunks, currentGroup, currentGroupIsEmphasis ?? false); currentCharacterCount = 0; + currentGroupIsEmphasis = null; chunks.Add(new ReaderPauseViewModel( pauseDuration, pauseDuration >= LongPauseThresholdMilliseconds @@ -190,6 +192,17 @@ private static IReadOnlyList BuildReaderChunks(IEnumerable continue; } + var isEmphasisWord = IsReaderWordEmphasis(word.Metadata); + if (currentGroup.Count > 0 && + currentGroupIsEmphasis.HasValue && + currentGroupIsEmphasis.Value != isEmphasisWord) + { + FlushGroup(chunks, currentGroup, currentGroupIsEmphasis.Value); + currentCharacterCount = 0; + currentGroupIsEmphasis = null; + } + + currentGroupIsEmphasis ??= isEmphasisWord; currentCharacterCount += word.CleanText.Length; if (currentGroup.Count > 0) { @@ -208,12 +221,13 @@ private static IReadOnlyList BuildReaderChunks(IEnumerable if (ShouldEndReaderGroup(word.CleanText, currentGroup.Count, currentCharacterCount)) { - FlushGroup(chunks, currentGroup); + FlushGroup(chunks, currentGroup, currentGroupIsEmphasis ?? false); currentCharacterCount = 0; + currentGroupIsEmphasis = null; } } - FlushGroup(chunks, currentGroup); + FlushGroup(chunks, currentGroup, currentGroupIsEmphasis ?? false); return chunks; } @@ -232,17 +246,20 @@ private static bool ShouldEndReaderGroup(string cleanText, int wordCount, int ch return HasClausePunctuation(cleanText) && wordCount >= 3; } - private static void FlushGroup(List chunks, List currentGroup) + private static void FlushGroup(List chunks, List currentGroup, bool isEmphasis) { if (currentGroup.Count == 0) { return; } - chunks.Add(new ReaderGroupViewModel(currentGroup.ToArray())); + chunks.Add(new ReaderGroupViewModel(currentGroup.ToArray(), isEmphasis)); currentGroup.Clear(); } + private static bool IsReaderWordEmphasis(WordMetadata? metadata) => + metadata?.IsEmphasis == true; + private static string BuildReaderWordBaseClass(WordMetadata? metadata, int targetWpm) { if (metadata is null) @@ -252,11 +269,6 @@ private static string BuildReaderWordBaseClass(WordMetadata? metadata, int targe var classes = new List(); - if (metadata.IsEmphasis) - { - classes.Add("tps-emphasis"); - } - var colorClass = ResolveColorClass(metadata.Color, TpsClassPrefix); if (!string.IsNullOrWhiteSpace(colorClass)) { diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs index 9b96849..8d83b14 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs @@ -34,24 +34,24 @@ private sealed record ReaderCardViewModel( string TestId) { public static ReaderCardViewModel Empty { get; } = new( - SectionName: "Introduction", - DisplayName: "Opening Block", + SectionName: string.Empty, + DisplayName: string.Empty, EmotionKey: "warm", - EmotionLabel: "Warm", + EmotionLabel: string.Empty, BackgroundClass: "warm", AccentColor: "#E97F00", - TargetWpm: 140, - WordCount: 1, - DurationMilliseconds: 1000, - WidthPercentString: "100%", + TargetWpm: 0, + WordCount: 0, + DurationMilliseconds: 0, + WidthPercentString: "0%", EdgeColor: "rgba(233, 127, 0, 0.35)", - Chunks: [new ReaderGroupViewModel([new ReaderWordViewModel("Ready", "tps-warm", 600)])], + Chunks: [], TestId: UiTestIds.Teleprompter.Card(999)); } private abstract record ReaderChunkViewModel; - private sealed record ReaderGroupViewModel(IReadOnlyList Words) : ReaderChunkViewModel; + private sealed record ReaderGroupViewModel(IReadOnlyList Words, bool IsEmphasis) : ReaderChunkViewModel; private sealed record ReaderPauseViewModel(int DurationMs, string CssClass) : ReaderChunkViewModel; diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs index 5ecc7a5..e5560f6 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs @@ -6,7 +6,7 @@ namespace PrompterOne.Shared.Pages; public partial class TeleprompterPage { private const int MinimumReaderLoopDelayMilliseconds = 120; - private const int ReaderCardTransitionMilliseconds = 850; + private const int ReaderCardTransitionMilliseconds = 600; private Task DecreaseReaderFontSizeAsync() => ChangeReaderFontSizeAsync(-ReaderFontStep); @@ -133,9 +133,8 @@ private async Task StartReaderCountdownAsync() await Task.Delay(ReaderFirstWordDelayMilliseconds, cancellationToken); - await AlignReaderCardTextAsync(_activeReaderCardIndex, 0, neutralizeCard: true, rerender: true, instantTransition: true); _isReaderPlaying = true; - await ActivatePreparedReaderWordAsync(0); + await ActivateReaderWordAsync(0, alignBeforeActivation: true); _ = RunReaderPlaybackLoopAsync(GetCurrentWordDelayMilliseconds(), cancellationToken); } catch (OperationCanceledException) @@ -157,22 +156,19 @@ private async Task StepReaderWordAsync(int direction) { if (_activeReaderWordIndex > 0) { - _activeReaderWordIndex--; - UpdateReaderDisplayState(); + await ActivateReaderWordAsync(_activeReaderWordIndex - 1, alignBeforeActivation: true); } } else if (_activeReaderWordIndex < 0) { - _activeReaderWordIndex = 0; - UpdateReaderDisplayState(); + await ActivateReaderWordAsync(0, alignBeforeActivation: true); } else { var wordCount = GetCardWordCount(_cards[_activeReaderCardIndex]); if (_activeReaderWordIndex < wordCount - 1) { - _activeReaderWordIndex++; - UpdateReaderDisplayState(); + await ActivateReaderWordAsync(_activeReaderWordIndex + 1, alignBeforeActivation: true); } else { @@ -197,8 +193,7 @@ private async Task JumpReaderCardAsync(int direction) StopReaderPlaybackLoop(keepPlaybackState: true); if (direction < 0 && _activeReaderWordIndex > 1) { - await PrepareReaderCardAlignmentAsync(_activeReaderCardIndex, 0); - await ActivatePreparedReaderWordAsync(0); + await ActivateReaderWordAsync(0, alignBeforeActivation: true); if (resumePlayback) { @@ -321,9 +316,7 @@ private async Task AdvanceReaderPlaybackAsync(CancellationToken cancellatio if (_activeReaderWordIndex < cardWordCount - 1) { - _activeReaderWordIndex++; - UpdateReaderDisplayState(); - await InvokeAsync(StateHasChanged); + await ActivateReaderWordAsync(_activeReaderWordIndex + 1, alignBeforeActivation: true); return GetCurrentWordDelayMilliseconds(); } @@ -351,7 +344,7 @@ private async Task AdvanceToCardAsync(int nextCardIndex, CancellationToken cance { return; } - await ActivatePreparedReaderWordAsync(0); + await ActivateReaderWordAsync(0, alignBeforeActivation: false); } finally { @@ -388,8 +381,13 @@ private int GetCurrentWordDelayMilliseconds() return MinimumReaderLoopDelayMilliseconds; } - private async Task ActivatePreparedReaderWordAsync(int wordIndex) + private async Task ActivateReaderWordAsync(int wordIndex, bool alignBeforeActivation) { + if (alignBeforeActivation) + { + await AlignReaderWordBeforeActivationAsync(_activeReaderCardIndex, wordIndex); + } + _activeReaderWordIndex = wordIndex; UpdateReaderDisplayState(requestAlignment: false); await InvokeAsync(StateHasChanged); diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs index 38ec823..c63555d 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs @@ -14,6 +14,7 @@ public partial class TeleprompterPage private const string ReaderCountdownCssClass = "rd-countdown"; private const string ReaderGroupActiveCssClass = "rd-g-active"; private const string ReaderGroupCssClass = "rd-g"; + private const string ReaderGroupEmphasisCssClass = "rd-g-emphasis"; private const string ReaderHorizontalGuideCssClass = "rd-guide-h"; private const string ReaderVerticalGuideCssClass = "rd-guide-v"; private const string ReaderVerticalGuideLeftCssClass = "rd-guide-v-l"; @@ -118,9 +119,12 @@ private string BuildReaderCardCssClass(int index) private string BuildReaderGroupCssClass(int cardIndex, int chunkIndex) { + var group = (ReaderGroupViewModel)_cards[cardIndex].Chunks[chunkIndex]; if (cardIndex != _activeReaderCardIndex || _activeReaderWordIndex < 0) { - return ReaderGroupCssClass; + return BuildClassList( + ReaderGroupCssClass, + group.IsEmphasis ? ReaderGroupEmphasisCssClass : null); } var groupStartIndex = GetChunkWordStartIndex(cardIndex, chunkIndex); @@ -128,7 +132,10 @@ private string BuildReaderGroupCssClass(int cardIndex, int chunkIndex) var isActiveGroup = _activeReaderWordIndex >= groupStartIndex && _activeReaderWordIndex < groupStartIndex + groupWordCount; - return BuildClassList(ReaderGroupCssClass, isActiveGroup ? ReaderGroupActiveCssClass : null); + return BuildClassList( + ReaderGroupCssClass, + group.IsEmphasis ? ReaderGroupEmphasisCssClass : null, + isActiveGroup ? ReaderGroupActiveCssClass : null); } private string BuildReaderWordCssClass(int cardIndex, int chunkIndex, int wordIndex) diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs index d1be0b8..19b3836 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs @@ -5,18 +5,18 @@ namespace PrompterOne.Shared.Pages; public partial class TeleprompterPage { - private const double FastLetterSpacingDeadZoneRatio = 0.05d; - private const double FastLetterSpacingFloorEm = -0.015d; - private const double MaximumFastLetterSpacingEm = -0.028d; - private const double MaximumSlowClassLetterSpacingEm = 0.055d; + private const double FastLetterSpacingDeadZoneRatio = 0.03d; + private const double FastLetterSpacingFloorEm = -0.024d; + private const double MaximumFastLetterSpacingEm = -0.05d; + private const double MaximumSlowClassLetterSpacingEm = 0.085d; private const int MinimumReaderReferenceWpm = 60; private const string PronunciationTitlePrefix = "Pronunciation: "; private const string ReaderSpeedTitlePrefix = "Speed: "; private const string ReaderWordLetterSpacingVariable = "--tps-word-letter-spacing"; - private const double SlowLetterSpacingFloorEm = 0.028d; - private const double SlowLetterSpacingRangeRatio = 0.4d; - private const double FastLetterSpacingRangeRatio = 0.55d; - private const double MaximumSlowLetterSpacingEm = 0.058d; + private const double SlowLetterSpacingFloorEm = 0.045d; + private const double SlowLetterSpacingRangeRatio = 0.32d; + private const double FastLetterSpacingRangeRatio = 0.45d; + private const double MaximumSlowLetterSpacingEm = 0.09d; private const string WpmSuffix = " WPM"; private static string? BuildReaderWordStyle(WordMetadata? metadata, int targetWpm, int effectiveWpm) diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor.cs index 52de844..bd9615a 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor.cs @@ -13,7 +13,6 @@ public partial class TeleprompterPage : IAsyncDisposable { private const int DefaultReaderFontSize = 36; private const int DefaultReaderFocalPointPercent = 30; - private const int DefaultReaderTextWidth = 750; private const int MaxReaderGroupCharacterCount = 24; private const int MaxReaderGroupWordCount = 5; private const string LoadReaderMessage = "Unable to prepare teleprompter playback."; @@ -33,6 +32,7 @@ public partial class TeleprompterPage : IAsyncDisposable private const int ReaderMinFontSize = 24; private const int ReaderMinTextWidth = 400; private const int ReaderMinFocalPointPercent = 15; + private const int DefaultReaderTextWidth = ReaderMaxTextWidth; [Inject] private AppBootstrapper Bootstrapper { get; set; } = null!; [Inject] private CameraPreviewInterop CameraPreviewInterop { get; set; } = null!; @@ -124,6 +124,7 @@ await Diagnostics.RunAsync( } await AlignActiveReaderTextAsync(); + await RestorePendingReaderTextTransitionsAsync(); } private async Task EnsureSessionLoadedAsync() diff --git a/src/PrompterOne.Shared/wwwroot/design/modules/30-rsvp.css b/src/PrompterOne.Shared/wwwroot/design/modules/30-rsvp.css index 64d3ea7..1425436 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/30-rsvp.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/30-rsvp.css @@ -85,9 +85,12 @@ /* Horizontal row: left context | focus | right context */ .rsvp-h-row { - --rsvp-focus-shift: 0px; + --rsvp-context-gap: clamp(24px, 2vw, 32px); + --rsvp-focus-left-extent: 0px; + --rsvp-focus-right-extent: 0px; position: relative; - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: baseline; justify-content: center; width: min(100%, 1100px); @@ -97,6 +100,22 @@ overflow: visible; } +.rsvp-lane { + min-width: 0; + display: flex; + align-items: baseline; +} + +.rsvp-lane-left { + justify-content: flex-end; + padding-right: calc(var(--rsvp-focus-left-extent) + var(--rsvp-context-gap)); +} + +.rsvp-lane-right { + justify-content: flex-start; + padding-left: calc(var(--rsvp-focus-right-extent) + var(--rsvp-context-gap)); +} + .rsvp-ctx-left, .rsvp-ctx-right { display: flex; align-items: baseline; @@ -106,29 +125,29 @@ color: rgba(232,213,176,.35); line-height: 1; white-space: nowrap; - overflow: clip; - flex: 1 1 0; + overflow: visible; + flex: 0 0 auto; + width: max-content; + max-width: 100%; min-width: 0; pointer-events: none; z-index: 0; } .rsvp-ctx-left { justify-content: flex-end; - padding-right: 60px; } .rsvp-ctx-right { justify-content: flex-start; - padding-left: 60px; } .rsvp-focus-shell { + justify-self: center; display: flex; align-items: baseline; - justify-content: center; - flex: 0 0 clamp(360px, 32vw, 520px); - width: clamp(360px, 32vw, 520px); - min-width: clamp(360px, 32vw, 520px); - max-width: clamp(360px, 32vw, 520px); + justify-content: flex-start; + width: 0; + min-width: 0; + max-width: none; overflow: visible; position: relative; z-index: 1; @@ -144,7 +163,7 @@ white-space: nowrap; flex: 0 0 auto; line-height: 1; - transform: translateX(var(--rsvp-focus-shift)); + transform: translateX(calc(-1 * var(--rsvp-focus-left-extent))); } .rsvp-focus-segment { color: var(--t1); diff --git a/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css b/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css index 39f90ab..f4977fa 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css @@ -161,7 +161,7 @@ .rd-cluster-wrap { position: relative; padding: 0 4%; - max-width: 750px; + max-width: 1100px; margin: 0 auto; width: 100%; height: 100%; @@ -222,7 +222,7 @@ .tps-professional { color: #5B7FFF; } /* ── TPS Speed Indicators (visual cue for speed tags) ── */ -.tps-xslow { letter-spacing: var(--tps-word-letter-spacing, 0.055em); } -.tps-slow { letter-spacing: var(--tps-word-letter-spacing, 0.028em); } -.tps-fast { letter-spacing: var(--tps-word-letter-spacing, -0.015em); } -.tps-xfast { letter-spacing: var(--tps-word-letter-spacing, -0.028em); opacity: .78; } +.tps-xslow { letter-spacing: var(--tps-word-letter-spacing, 0.085em); } +.tps-slow { letter-spacing: var(--tps-word-letter-spacing, 0.045em); } +.tps-fast { letter-spacing: var(--tps-word-letter-spacing, -0.024em); } +.tps-xfast { letter-spacing: var(--tps-word-letter-spacing, -0.05em); opacity: .82; } diff --git a/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css b/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css index 1b764d7..9e1b8a3 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css @@ -14,7 +14,7 @@ display: flex; align-items: center; justify-content: center; - transition: transform .8s cubic-bezier(.4,0,.2,1), opacity .8s ease; + transition: transform .56s cubic-bezier(.22,.84,.24,1), opacity .32s ease-out; will-change: transform, opacity; overflow: visible; } @@ -94,11 +94,10 @@ word-spacing: 0.08em; overflow-wrap: break-word; hyphens: none; + font-kerning: normal; + font-feature-settings: "kern" 1; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /* Smooth line centering */ - transition: transform 0.35s cubic-bezier(0.25, 0.1, 0.25, 1); - will-change: transform; } /* ── Semantic phrase groups ── */ @@ -117,6 +116,14 @@ white-space: nowrap; } +.rd-g-emphasis { + text-decoration: underline; + text-decoration-color: var(--accent); + text-underline-offset: 4px; + text-decoration-thickness: 2px; + text-decoration-skip-ink: auto; +} + /* ── Pause markers — visual breathing points between phrases ── Research: pauses at clause boundaries = breath groups. Short pauses (/) stay inline with a subtle dot; sentence-ending pauses (//) force @@ -250,7 +257,7 @@ span.rd-pause.rd-pause-long { .rd-g-active > .rd-w.tps-calm:not(.rd-now):not(.rd-read) { color: rgba(56,217,169,.75); } .rd-g-active > .rd-w.tps-energetic:not(.rd-now):not(.rd-read) { color: rgba(255,99,71,.78); } .rd-g-active > .rd-w.tps-professional:not(.rd-now):not(.rd-read) { color: rgba(91,127,255,.78); } -.rd-g-active > .rd-w.tps-emphasis:not(.rd-now):not(.rd-read) { text-decoration-color: rgba(232,213,176,.45); } +.rd-g-active.rd-g-emphasis { text-decoration-color: rgba(232,213,176,.45); } .rd-g-active > .rd-w.tps-highlight:not(.rd-now):not(.rd-read) { background: rgba(255,224,102,.18); } /* Word states — NO font-size changes to prevent layout jumping */ @@ -310,8 +317,11 @@ span.rd-pause.rd-pause-long { .rd-w.rd-now.tps-energetic { color: #FF6347; text-shadow: 0 0 40px rgba(255,99,71,.3); } .rd-w.rd-now.tps-professional { color: #5B7FFF; text-shadow: 0 0 40px rgba(91,127,255,.3); } -/* Active word with emphasis/strong */ -.rd-w.rd-now.tps-emphasis, +/* Active word with strong emphasis */ +.rd-g-emphasis .rd-w.rd-now { + text-decoration: none; +} + .rd-w.rd-now.tps-strong { text-decoration-color: var(--accent); text-shadow: 0 0 50px rgba(232,130,92,.22); 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 f3de832..b4525bf 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/settings/20-reference.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/settings/20-reference.css @@ -290,7 +290,7 @@ border: 1px solid var(--gold-06); border-radius: var(--r-lg); overflow: hidden; - opacity: .5; + opacity: .75; transition: opacity .2s, border-color .2s, box-shadow .2s; } @@ -385,7 +385,7 @@ } .set-mic-preview.set-mic-off { - opacity: .35; + opacity: .55; color: var(--t4); background: rgba(10, 14, 20, .3); } diff --git a/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js b/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js index 1ea728a..42f6203 100644 --- a/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js +++ b/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js @@ -2,7 +2,8 @@ const interopNamespace = "LearnRsvpLayoutInterop"; const focusSelector = ".rsvp-focus"; const orpSelector = ".rsvp-focus-orp"; - const focusShiftPropertyName = "--rsvp-focus-shift"; + const focusLeftExtentPropertyName = "--rsvp-focus-left-extent"; + const focusRightExtentPropertyName = "--rsvp-focus-right-extent"; const fontSyncReadyAttributeName = "data-rsvp-layout-font-sync-ready"; const pixelUnitSuffix = "px"; const zeroPixels = "0px"; @@ -12,12 +13,9 @@ target.style.setProperty(propertyName, `${roundedValue}${pixelUnitSuffix}`); } - function readCenter(rect) { - return rect.left + (rect.width / 2); - } - function applyDefaultLayout(rowElement) { - rowElement.style.setProperty(focusShiftPropertyName, zeroPixels); + rowElement.style.setProperty(focusLeftExtentPropertyName, zeroPixels); + rowElement.style.setProperty(focusRightExtentPropertyName, zeroPixels); } function syncLayoutNow(rowElement) { @@ -34,9 +32,12 @@ const focusRect = focusElement.getBoundingClientRect(); const orpRect = orpElement.getBoundingClientRect(); - const focusShiftPx = readCenter(focusRect) - readCenter(orpRect); + const orpCenterPx = orpRect.left + (orpRect.width / 2); + const focusLeftExtentPx = Math.max(orpCenterPx - focusRect.left, 0); + const focusRightExtentPx = Math.max(focusRect.right - orpCenterPx, 0); - setPixelProperty(rowElement, focusShiftPropertyName, focusShiftPx); + setPixelProperty(rowElement, focusLeftExtentPropertyName, focusLeftExtentPx); + setPixelProperty(rowElement, focusRightExtentPropertyName, focusRightExtentPx); } function scheduleFontReadySync(rowElement) { diff --git a/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs b/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs index c286268..deebaf9 100644 --- a/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs +++ b/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs @@ -75,6 +75,23 @@ public void GoLivePage_RendersProductionStudioLayoutLandmarks() }); } + [Fact] + public void GoLivePage_TopLeftHomeControl_TargetsLibraryRoute() + { + SeedSceneState(CreateTwoCameraScene()); + + Services.GetRequiredService() + .NavigateTo(AppTestData.Routes.GoLiveDemo); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + var homeLink = cut.FindByTestId(UiTestIds.GoLive.OpenHome); + Assert.Equal(AppRoutes.Library, homeLink.GetAttribute("href")); + }); + } + [Fact] public void GoLivePage_TogglesProgramSourcesAndPersistsScene() { diff --git a/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs b/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs index 4718b05..122f7d8 100644 --- a/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs +++ b/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs @@ -12,18 +12,25 @@ public sealed class TeleprompterFidelityTests : BunitContext { private const int BenefitsCardIndex = 5; private const int ClosingCardIndex = 7; + private const string ContinuousEmphasisCssClass = "rd-g-emphasis"; private const string GreenWord = "transformative"; private const string HighlightWord = "solution"; private const int IntroductionCardIndex = 4; private const string IntroductionWord = "comes"; private const int InspirationCardIndex = 6; + private const double MinimumVisibleFastLetterSpacingEm = -0.024d; + private const double MinimumVisibleSlowLetterSpacingEm = 0.045d; + private const string MaximumReaderWidth = "1100"; private const string NeutralWord = "Good"; private const int OpeningCardIndex = 0; private const int PurposeCardIndex = 1; + private const int SecurityIncidentResponseCardIndex = 2; + private const string SecurityIncidentStandaloneComma = ","; private const int SpeedOffsetsCardIndex = 0; private const int StatisticsCardIndex = 2; private const string FastWord = "Full"; private const string PurpleWord = "focus"; + private const string SecurityIncidentEmphasisPhrase = "No payment data was exposed,"; private const string SlowWord = "elephant"; private const string SpeedOffsetsFastWord = "flight"; private const string SpeedOffsetsNormalWord = "center"; @@ -66,6 +73,22 @@ public void TeleprompterPage_UsesReferenceSizedReaderGroupsForSecurityIncident() }); } + [Fact] + public void TeleprompterPage_StartsWithMaximumReaderWidthByDefault() + { + var harness = TestHarnessFactory.Create(this); + Services.GetRequiredService() + .NavigateTo(AppTestData.Routes.TeleprompterDemo); + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Equal(MaximumReaderWidth, cut.FindByTestId(UiTestIds.Teleprompter.WidthSlider).GetAttribute("value")); + Assert.Equal(MaximumReaderWidth, cut.Find($"#{UiDomIds.Teleprompter.WidthValue}").TextContent.Trim()); + Assert.Contains($"max-width:{MaximumReaderWidth}px;", cut.Find($"#{UiDomIds.Teleprompter.ClusterWrap}").GetAttribute("style"), StringComparison.Ordinal); + }); + } + [Fact] public void TeleprompterPage_PropagatesTpsWordFormattingTimingAndPronunciationMetadata() { @@ -85,9 +108,11 @@ public void TeleprompterPage_PropagatesTpsWordFormattingTimingAndPronunciationMe Assert.Contains("tps-xslow", slowWord.ClassName, StringComparison.Ordinal); Assert.Contains("--tps-word-letter-spacing:", slowWord.GetAttribute("style"), StringComparison.Ordinal); Assert.Contains("Speed: 90 WPM", slowWord.GetAttribute("title"), StringComparison.Ordinal); + Assert.True(GetLetterSpacingEm(slowWord) >= MinimumVisibleSlowLetterSpacingEm); Assert.Contains("tps-xfast", fastWord.ClassName, StringComparison.Ordinal); Assert.Contains("--tps-word-letter-spacing:-", fastWord.GetAttribute("style"), StringComparison.Ordinal); + Assert.True(GetLetterSpacingEm(fastWord) <= MinimumVisibleFastLetterSpacingEm); Assert.True(GetWordDurationMilliseconds(slowWord) > GetWordDurationMilliseconds(fastWord)); Assert.Contains("tps-purple", purpleWord.ClassName, StringComparison.Ordinal); @@ -121,6 +146,7 @@ public void TeleprompterPage_UsesCustomFrontMatterSpeedOffsetsAndNormalReset() Assert.Equal(SpeedOffsetsSlowWpm, slowWord.GetAttribute("data-effective-wpm")); Assert.Contains("Speed: 126 WPM", slowWord.GetAttribute("title"), StringComparison.Ordinal); Assert.Contains("--tps-word-letter-spacing:", slowWord.GetAttribute("style"), StringComparison.Ordinal); + Assert.True(GetLetterSpacingEm(slowWord) >= MinimumVisibleSlowLetterSpacingEm); Assert.Equal("140", normalWord.GetAttribute("data-effective-wpm")); Assert.False(normalWordClassName.Contains("tps-slow", StringComparison.Ordinal)); @@ -134,6 +160,7 @@ public void TeleprompterPage_UsesCustomFrontMatterSpeedOffsetsAndNormalReset() Assert.Contains("tps-fast", fastWord.ClassName, StringComparison.Ordinal); Assert.Equal(SpeedOffsetsFastWpm, fastWord.GetAttribute("data-effective-wpm")); Assert.Contains("--tps-word-letter-spacing:-", fastWord.GetAttribute("style"), StringComparison.Ordinal); + Assert.True(GetLetterSpacingEm(fastWord) <= MinimumVisibleFastLetterSpacingEm); Assert.True(GetWordDurationMilliseconds(slowWord) > GetWordDurationMilliseconds(normalWord)); Assert.True(GetWordDurationMilliseconds(resumedSlowWord) > GetWordDurationMilliseconds(normalWord)); @@ -179,6 +206,29 @@ public void TeleprompterPage_StylesOnlyExplicitInlineTpsEmotionAndColorTags() }); } + [Fact] + public void TeleprompterPage_RendersContinuousEmphasisGroupsAndNoStandalonePunctuationWords() + { + var harness = TestHarnessFactory.Create(this); + Services.GetRequiredService() + .NavigateTo(AppTestData.Routes.TeleprompterSecurityIncident); + var cut = Render(); + + cut.WaitForAssertion(() => + { + var emphasisGroup = cut.FindByTestId(UiTestIds.Teleprompter.CardGroup(SecurityIncidentResponseCardIndex, 0)); + var responseWords = cut.FindByTestId(UiTestIds.Teleprompter.CardText(SecurityIncidentResponseCardIndex)) + .QuerySelectorAll(".rd-w") + .Select(element => element.TextContent.Trim()) + .ToArray(); + + Assert.Contains(SecurityIncidentEmphasisPhrase, emphasisGroup.TextContent, StringComparison.Ordinal); + Assert.Contains(ContinuousEmphasisCssClass, emphasisGroup.ClassName, StringComparison.Ordinal); + Assert.Contains("exposed,", responseWords); + Assert.DoesNotContain(SecurityIncidentStandaloneComma, responseWords); + }); + } + [Fact] public void TeleprompterPage_UsesDarkReaderBackgroundForGreenArchitectureRoute() { @@ -205,4 +255,16 @@ private static AngleSharp.Dom.IElement FindReaderWordByText(IRenderedComponent int.Parse(word.GetAttribute("data-ms")!, CultureInfo.InvariantCulture); + + private static double GetLetterSpacingEm(AngleSharp.Dom.IElement word) + { + var style = word.GetAttribute("style") ?? string.Empty; + var value = style + .Split(':', 2, StringSplitOptions.TrimEntries) + .LastOrDefault()? + .Replace("em;", string.Empty, StringComparison.Ordinal) + .Trim(); + + return double.Parse(value ?? "0", CultureInfo.InvariantCulture); + } } diff --git a/tests/PrompterOne.App.UITests/GoLive/GoLiveFlowTests.cs b/tests/PrompterOne.App.UITests/GoLive/GoLiveFlowTests.cs index 0ff8163..2739d66 100644 --- a/tests/PrompterOne.App.UITests/GoLive/GoLiveFlowTests.cs +++ b/tests/PrompterOne.App.UITests/GoLive/GoLiveFlowTests.cs @@ -59,7 +59,7 @@ await page.WaitForFunctionAsync( } [Fact] - public async Task GoLivePage_TogglesSceneCameraMembershipAndLinksBackToRead() + public async Task GoLivePage_TogglesSceneCameraMembershipAndRoutesTopLeftHomeControlToLibrary() { var page = await _fixture.NewPageAsync(); @@ -76,9 +76,9 @@ public async Task GoLivePage_TogglesSceneCameraMembershipAndLinksBackToRead() await sourceButton.ClickAsync(); await Expect(sourceButton).ToContainTextAsync(RemoveActionLabel); - await page.GetByTestId(UiTestIds.GoLive.OpenRead).ClickAsync(); - await page.WaitForURLAsync(BrowserTestConstants.Routes.Pattern(BrowserTestConstants.Routes.TeleprompterDemo)); - await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)).ToBeVisibleAsync(); + await page.GetByTestId(UiTestIds.GoLive.OpenHome).ClickAsync(); + await page.WaitForURLAsync(BrowserTestConstants.Routes.Pattern(BrowserTestConstants.Routes.Library)); + await Expect(page.GetByTestId(UiTestIds.Library.Page)).ToBeVisibleAsync(); } finally { diff --git a/tests/PrompterOne.App.UITests/Learn/LearnWordLaneStabilityTests.cs b/tests/PrompterOne.App.UITests/Learn/LearnWordLaneStabilityTests.cs index 8960dae..00a0a68 100644 --- a/tests/PrompterOne.App.UITests/Learn/LearnWordLaneStabilityTests.cs +++ b/tests/PrompterOne.App.UITests/Learn/LearnWordLaneStabilityTests.cs @@ -9,13 +9,12 @@ public sealed class LearnWordLaneStabilityTests(StandaloneAppFixture fixture) : private const string LongProbeWord = "hype"; private const string ShortProbeWord = "It"; private const int StabilityProbeStepLimit = 120; - private const double MaxShellCenterDriftPx = 2; - private const double MaxLeftRailEdgeDriftPx = 2; - private const double MaxRightRailEdgeDriftPx = 2; + private const double MaxOrpCenterDriftPx = 2; + private const double MaxVisibleContextGapDriftPx = 2; private readonly StandaloneAppFixture _fixture = fixture; [Fact] - public async Task LearnScreen_QuantumWordLengthChanges_DoNotShiftTheRsvpLane() + public async Task LearnScreen_QuantumWordLengthChanges_KeepTheOrpAnchorAndVisibleContextGapsStable() { var page = await _fixture.NewPageAsync(); @@ -36,17 +35,17 @@ await Expect(page.GetByTestId(UiTestIds.Learn.Page)) var shortWordLane = await MeasureRsvpLaneAsync(page); Assert.InRange( - Math.Abs(longWordLane.ShellCenterPx - shortWordLane.ShellCenterPx), + Math.Abs(longWordLane.OrpCenterPx - shortWordLane.OrpCenterPx), 0, - MaxShellCenterDriftPx); + MaxOrpCenterDriftPx); Assert.InRange( - Math.Abs(longWordLane.LeftRailRightPx - shortWordLane.LeftRailRightPx), + Math.Abs(longWordLane.LeftVisibleGapPx - shortWordLane.LeftVisibleGapPx), 0, - MaxLeftRailEdgeDriftPx); + MaxVisibleContextGapDriftPx); Assert.InRange( - Math.Abs(longWordLane.RightRailLeftPx - shortWordLane.RightRailLeftPx), + Math.Abs(longWordLane.RightVisibleGapPx - shortWordLane.RightVisibleGapPx), 0, - MaxRightRailEdgeDriftPx); + MaxVisibleContextGapDriftPx); } finally { @@ -58,27 +57,31 @@ private static Task MeasureRsvpLaneAsync(IPage page) => page.EvaluateAsync( """ ids => { - const shell = document.querySelector(`[data-testid="${ids.shell}"]`); + const word = document.querySelector(`[data-testid="${ids.word}"]`); + const orp = word?.querySelector('.orp'); const leftRail = document.querySelector(`[data-testid="${ids.left}"]`); const rightRail = document.querySelector(`[data-testid="${ids.right}"]`); - if (!shell || !leftRail || !rightRail) { - return { shellCenterPx: -999, leftRailRightPx: -999, rightRailLeftPx: -999 }; + const leftWord = leftRail?.lastElementChild; + const rightWord = rightRail?.firstElementChild; + if (!word || !orp || !leftWord || !rightWord) { + return { orpCenterPx: -999, leftVisibleGapPx: -999, rightVisibleGapPx: -999 }; } - const shellRect = shell.getBoundingClientRect(); - const leftRailRect = leftRail.getBoundingClientRect(); - const rightRailRect = rightRail.getBoundingClientRect(); + const wordRect = word.getBoundingClientRect(); + const orpRect = orp.getBoundingClientRect(); + const leftWordRect = leftWord.getBoundingClientRect(); + const rightWordRect = rightWord.getBoundingClientRect(); return { - shellCenterPx: shellRect.left + (shellRect.width / 2), - leftRailRightPx: leftRailRect.right, - rightRailLeftPx: rightRailRect.left + orpCenterPx: orpRect.left + (orpRect.width / 2), + leftVisibleGapPx: wordRect.left - leftWordRect.right, + rightVisibleGapPx: rightWordRect.left - wordRect.right }; } """, new { - shell = UiTestIds.Learn.WordShell, + word = UiTestIds.Learn.Word, left = UiTestIds.Learn.ContextLeft, right = UiTestIds.Learn.ContextRight }); @@ -115,7 +118,7 @@ private static async Task ReadFocusWordAsync(IPage page) } private readonly record struct RsvpLaneMeasurement( - double ShellCenterPx, - double LeftRailRightPx, - double RightRailLeftPx); + double OrpCenterPx, + double LeftVisibleGapPx, + double RightVisibleGapPx); } diff --git a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs index 616927f..5b2cb59 100644 --- a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs +++ b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs @@ -99,7 +99,7 @@ public static class TeleprompterFlow localStorage.setItem('prompterone.settings.prompterone.reader', JSON.stringify({ CountdownSeconds: 3, FontScale: 1, - TextWidth: 0.72, + TextWidth: 1, ScrollSpeed: 1, MirrorText: false, ShowFocusLine: true, diff --git a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs index 46bfbd2..f9cf85a 100644 --- a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs +++ b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using PrompterOne.Shared.Contracts; using static Microsoft.Playwright.Assertions; @@ -5,8 +6,15 @@ namespace PrompterOne.App.UITests; public sealed class TeleprompterFidelityTests(StandaloneAppFixture fixture) : IClassFixture { + private const string ContinuousEmphasisCssClass = "rd-g-emphasis"; + private const int ImmediateAlignmentFollowUpDelayMilliseconds = 180; + private const string MaximumReaderWidthCss = "1100px"; + private const string MaximumReaderWidthValue = "1100"; private const int ParagraphMotionSettleDelayMilliseconds = 450; private const double ParagraphMotionTolerancePixels = 4d; + private const int SecurityIncidentResponseCardIndex = 2; + private const string StandaloneCommaWord = ","; + private const string TrailingCommaWord = "exposed,"; [Fact] public async Task TeleprompterLeadership_RepositionsReadingLineWhenFocalPointChanges() @@ -128,6 +136,80 @@ await AssertParagraphMotionStableAfterFontSizeChangeAsync( } } + [Fact] + public async Task TeleprompterDemo_ActivatesNextWordDirectlyOnFocalGuideWithoutVisibleSettling() + { + var page = await fixture.NewPageAsync(); + + try + { + await page.GotoAsync(BrowserTestConstants.Routes.TeleprompterDemo); + await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + + await page.WaitForTimeoutAsync(ParagraphMotionSettleDelayMilliseconds); + + var focalGuide = page.GetByTestId(UiTestIds.Teleprompter.FocalGuide); + await page.GetByTestId(UiTestIds.Teleprompter.NextWord).ClickAsync(); + + var activeWord = page.GetByTestId(UiTestIds.Teleprompter.CardText(0)).Locator(".rd-now"); + await Expect(activeWord).ToBeVisibleAsync(); + + var immediateDelta = await MeasureVerticalCenterDeltaAsync(focalGuide, activeWord); + await page.WaitForTimeoutAsync(ImmediateAlignmentFollowUpDelayMilliseconds); + var settledDelta = await MeasureVerticalCenterDeltaAsync(focalGuide, activeWord); + + Assert.InRange( + Math.Abs(immediateDelta), + 0d, + BrowserTestConstants.Teleprompter.AlignmentTolerancePx); + Assert.InRange( + Math.Abs(settledDelta), + 0d, + BrowserTestConstants.Teleprompter.AlignmentTolerancePx); + Assert.InRange( + Math.Abs(immediateDelta - settledDelta), + 0d, + ParagraphMotionTolerancePixels); + } + finally + { + await page.Context.CloseAsync(); + } + } + + [Fact] + public async Task TeleprompterSecurityIncident_UsesMaximumWidthAndContinuousEmphasisWithoutStandaloneCommaWords() + { + var page = await fixture.NewPageAsync(); + + try + { + await page.GotoAsync(BrowserTestConstants.Routes.TeleprompterSecurityIncident); + await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + + await Expect(page.GetByTestId(UiTestIds.Teleprompter.WidthSlider)).ToHaveValueAsync(MaximumReaderWidthValue); + await Expect(page.Locator($"#{UiDomIds.Teleprompter.WidthValue}")).ToHaveTextAsync(MaximumReaderWidthValue); + await Expect(page.GetByTestId(UiTestIds.Teleprompter.CardGroup(SecurityIncidentResponseCardIndex, 0))) + .ToHaveClassAsync(new($@"\b{ContinuousEmphasisCssClass}\b")); + + var clusterWrapWidth = await page.Locator($"#{UiDomIds.Teleprompter.ClusterWrap}") + .EvaluateAsync("element => getComputedStyle(element).maxWidth"); + var responseWords = await page.GetByTestId(UiTestIds.Teleprompter.CardText(SecurityIncidentResponseCardIndex)) + .Locator(".rd-w") + .AllTextContentsAsync(); + + Assert.Equal(MaximumReaderWidthCss, clusterWrapWidth); + Assert.Contains(TrailingCommaWord, responseWords); + Assert.DoesNotContain(StandaloneCommaWord, responseWords); + } + finally + { + await page.Context.CloseAsync(); + } + } + private static async Task EnsureCameraLayerIsActiveAsync(Microsoft.Playwright.IPage page) { var cameraLayer = page.GetByTestId(UiTestIds.Teleprompter.CameraBackground); diff --git a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs index 2e46a6d..13adc59 100644 --- a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs +++ b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterSettingsFlowTests.cs @@ -289,7 +289,7 @@ private static Task SeedStoredTeleprompterSceneAsync(Microsoft.Playwright.IPage localStorage.setItem('prompterone.settings.prompterone.reader', JSON.stringify({ CountdownSeconds: 3, FontScale: 1, - TextWidth: 0.72, + TextWidth: 1, ScrollSpeed: 1, MirrorText: false, ShowFocusLine: true, diff --git a/tests/PrompterOne.Core.Tests/Tps/TpsRoundTripTests.cs b/tests/PrompterOne.Core.Tests/Tps/TpsRoundTripTests.cs index b6a540c..473f73e 100644 --- a/tests/PrompterOne.Core.Tests/Tps/TpsRoundTripTests.cs +++ b/tests/PrompterOne.Core.Tests/Tps/TpsRoundTripTests.cs @@ -160,4 +160,37 @@ Neutral [warm]welcome[/warm] [urgent]act[/urgent] Assert.Equal("urgent", words["act"].Metadata.EmotionHint); Assert.Equal("urgent", words["act"].Metadata.InlineEmotionHint); } + + [Fact] + public async Task CompileAsync_AttachesStandalonePunctuationTokensToAdjacentWords() + { + var parser = new TpsParser(); + var compiler = new ScriptCompiler(); + const string source = """ + --- + title: "Attached punctuation" + base_wpm: 140 + --- + + ## [Signal|140WPM|focused] + + ### [Reader Block|140WPM] + + [emphasis]No payment data was exposed[/emphasis], / containment - restored. + """; + + var document = await parser.ParseAsync(source); + var compiled = await compiler.CompileAsync(document); + var words = compiled.Segments + .SelectMany(segment => segment.Blocks) + .SelectMany(block => block.Words) + .Where(word => word.Metadata?.IsPause != true && !string.IsNullOrWhiteSpace(word.CleanText)) + .Select(word => word.CleanText) + .ToArray(); + + Assert.Contains("exposed,", words); + Assert.Contains("containment -", words); + Assert.DoesNotContain(",", words); + Assert.DoesNotContain("-", words); + } } From 28d9d4d43198a3bdb24171c8a6a3ba4d1a377fce Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Thu, 2 Apr 2026 00:13:45 +0200 Subject: [PATCH 2/5] fxies --- AGENTS.md | 11 + design/app.js | 4 +- design/rsvp.html | 4 +- docs/Features/GoLiveRuntime.md | 17 +- docs/Features/ReaderRuntime.md | 6 + src/PrompterOne.App/wwwroot/index.html | 1 + .../Models/StreamingDestinationFieldIds.cs | 12 + .../Streaming/Models/StreamingProfile.cs | 157 +++++- .../Services/GoLiveDestinationRouting.cs | 15 +- .../Workspace/Models/GoLiveTargetCatalog.cs | 20 +- .../Workspace/Models/LearnSettings.cs | 13 +- .../Workspace/Models/LearnSettingsDefaults.cs | 13 + .../Workspace/Models/StudioSettings.cs | 3 + .../AppShell/Services/AppBootstrapper.cs | 33 +- .../AppShell/Services/GoLiveSessionState.cs | 64 ++- .../PrompterOneServiceCollectionExtensions.cs | 1 + src/PrompterOne.Shared/Contracts/UiTestIds.cs | 50 ++ .../Components/GoLiveCameraPreviewCard.razor | 12 +- .../GoLive/Components/GoLiveSourcesCard.razor | 23 +- .../Components/GoLiveStudioSidebar.razor | 56 +-- .../Components/GoLiveStudioSidebar.razor.css | 8 + .../GoLive/Models/GoLiveText.cs | 156 ++++++ .../GoLive/Pages/GoLivePage.Bootstrap.cs | 15 +- .../GoLive/Pages/GoLivePage.Descriptors.cs | 7 +- .../GoLive/Pages/GoLivePage.Destinations.cs | 222 ++++++--- .../GoLive/Pages/GoLivePage.Runtime.cs | 7 - .../GoLive/Pages/GoLivePage.Session.cs | 124 ++--- .../GoLive/Pages/GoLivePage.StudioSurface.cs | 245 ++------- .../GoLive/Pages/GoLivePage.razor | 15 +- .../GoLive/Pages/GoLivePage.razor.cs | 20 +- .../Services/GoLiveOutputRequestFactory.cs | 43 +- .../Learn/Pages/LearnPage.Playback.cs | 6 +- .../Learn/Pages/LearnPage.razor | 7 +- .../Learn/Pages/LearnPage.razor.cs | 9 +- .../Learn/Services/LearnRsvpLayoutContract.cs | 8 + .../Learn/Services/LearnRsvpLayoutInterop.cs | 11 +- .../Services/BrowserMediaDeviceService.cs | 7 +- .../StreamingPlatformPresentationCatalog.cs | 48 ++ .../StreamingPublishDescriptorResolver.cs | 28 ++ ...ingsStreamingExternalDestinationCard.razor | 221 +++++++++ .../Components/SettingsStreamingPanel.razor | 463 ++---------------- .../SettingsStreamingPanel.razor.cs | 149 ++++++ .../Models/SettingsStreamingCardIds.cs | 15 + .../SettingsStreamingLocalTargetCatalog.cs | 40 ++ .../Settings/Models/SettingsStreamingText.cs | 37 ++ .../Settings/Pages/SettingsPage.Streaming.cs | 375 +++++--------- .../Settings/Pages/SettingsPage.razor | 23 +- .../Services/StreamingSettingsNormalizer.cs | 301 ++++++++++-- .../Pages/TeleprompterPage.ReaderContent.cs | 11 +- .../Pages/TeleprompterPage.ReaderModels.cs | 18 +- .../Teleprompter/Pages/TeleprompterPage.razor | 4 - .../Pages/TeleprompterPage.razor.cs | 2 +- .../design/modules/reader/00-shell.css | 50 +- .../modules/reader/10-reading-states.css | 118 ++--- .../wwwroot/learn/learn-rsvp-layout.js | 26 +- .../wwwroot/media/browser-media.js | 22 +- .../AppBootstrapperLearnSettingsTests.cs | 67 +++ .../AppShell/ScreenShellContractTests.cs | 3 +- .../GoLive/GoLiveLiveIndicatorsTests.cs | 101 ++++ .../GoLive/GoLivePageTests.cs | 58 ++- .../GoLive/GoLiveSessionInteractionTests.cs | 42 +- .../Reader/ReaderStartupStateTests.cs | 8 +- .../Reader/ReaderStylesheetContractTests.cs | 12 +- .../Settings/SettingsInteractionTests.cs | 27 + .../Support/AppTestData.cs | 35 ++ .../Support/AppTestLibrarySeedData.cs | 6 + .../Teleprompter/TeleprompterFidelityTests.cs | 9 +- .../GoLive/GoLiveLiveIndicatorsFlowTests.cs | 91 ++++ .../Learn/EditorLearnScreenFlowTests.cs | 2 +- .../Media/BrowserTestConstants.Media.cs | 4 + .../Media/MediaRuntimeIntegrationTests.cs | 47 ++ .../Media/synthetic-media-harness.js | 48 +- .../Reader/ReaderPlaybackTimingTests.cs | 261 ++++++++++ .../BrowserTestConstants.ScreenFlows.cs | 30 +- .../Support/BrowserTestConstants.cs | 62 ++- .../Teleprompter/TeleprompterFidelityTests.cs | 2 +- .../Teleprompter/TeleprompterFullFlowTests.cs | 1 + .../TeleprompterStylesheetFlowTests.cs | 38 ++ .../GoLiveDestinationRoutingTests.cs | 22 +- .../Workspace/LearnSettingsDefaultsTests.cs | 20 + tests/TestData/Scripts/test-reader-timing.tps | 15 + 81 files changed, 3007 insertions(+), 1380 deletions(-) create mode 100644 src/PrompterOne.Core/Streaming/Models/StreamingDestinationFieldIds.cs create mode 100644 src/PrompterOne.Core/Workspace/Models/LearnSettingsDefaults.cs create mode 100644 src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs create mode 100644 src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutContract.cs create mode 100644 src/PrompterOne.Shared/Services/StreamingPlatformPresentationCatalog.cs create mode 100644 src/PrompterOne.Shared/Services/StreamingPublishDescriptorResolver.cs create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsStreamingExternalDestinationCard.razor create mode 100644 src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor.cs create mode 100644 src/PrompterOne.Shared/Settings/Models/SettingsStreamingCardIds.cs create mode 100644 src/PrompterOne.Shared/Settings/Models/SettingsStreamingLocalTargetCatalog.cs create mode 100644 src/PrompterOne.Shared/Settings/Models/SettingsStreamingText.cs create mode 100644 tests/PrompterOne.App.Tests/AppShell/AppBootstrapperLearnSettingsTests.cs create mode 100644 tests/PrompterOne.App.Tests/GoLive/GoLiveLiveIndicatorsTests.cs create mode 100644 tests/PrompterOne.App.UITests/GoLive/GoLiveLiveIndicatorsFlowTests.cs create mode 100644 tests/PrompterOne.App.UITests/Reader/ReaderPlaybackTimingTests.cs create mode 100644 tests/PrompterOne.App.UITests/Teleprompter/TeleprompterStylesheetFlowTests.cs create mode 100644 tests/PrompterOne.Core.Tests/Workspace/LearnSettingsDefaultsTests.cs create mode 100644 tests/TestData/Scripts/test-reader-timing.tps diff --git a/AGENTS.md b/AGENTS.md index 76c09fb..88606c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -278,6 +278,8 @@ Local `AGENTS.md` files may tighten these values, but they must not loosen them - String literals are forbidden in implementation code. Declare them once as named constants, enums, configuration entries, or dedicated value objects, then reuse those symbols. - Avoid magic literals. Extract shared values into constants, enums, configuration, or dedicated types. - URLs, storage keys, JS interop identifiers, route fragments, and user-visible fallback strings are implementation literals too. They MUST live behind named constants or localized catalogs. +- JS bridges must not invent repo-owned CSS custom-property names, DOM selectors, or feature data-attribute names inside `.js` files when Blazor/C# can own and pass those contract strings explicitly. +- Fallback device names, fallback device labels, and other invented media-device placeholders are forbidden in runtime and tests; use real device metadata when it exists, otherwise keep the field empty or assert on explicit no-device state instead of fabricating names. - Design boundaries so real behaviour can be tested through public interfaces. - The repo-root `.editorconfig` is the source of truth for formatting, naming, style, and analyzer severity. Use nested `.editorconfig` files only when they clearly serve a subtree-specific purpose. @@ -309,11 +311,19 @@ Repo-specific design rules: - Teleprompter reader word styling MUST mirror TPS/editor inline semantics: explicit inline TPS tags control per-word emphasis and color, while section or block emotion sets card context and must not recolor every reader word. - Teleprompter underline or highlight treatments that span a phrase or block MUST render as one continuous block-level treatment; separate per-word underlines inside the same phrase are forbidden. - Teleprompter reader text MUST appear on the focal guide immediately when a word or block becomes active; visible post-appearance drift or settling onto the guide is forbidden. +- Teleprompter route styles MUST be present on the first paint; a flash of unstyled or late-styled reader UI during route entry is a regression. - Teleprompter block transitions MUST stay visually consistent: outgoing cards move upward and incoming cards rise from below in the same direction every time; alternating up/down travel is forbidden, and extra settling, bounce, or intermediate card states are forbidden. +- Teleprompter focus treatment MUST stay visually calm: the active focus word may be emphasized, but surrounding text should be gently dimmed instead of creating a bright moving blot, fake box, or attention-grabbing patch that flies up and down. +- Teleprompter emotion styling may tint the surface or accents, but reader text itself MUST stay easy to read and must not become harsh, over-bright, or saturated enough to hurt readability. +- Learn and Teleprompter playback timing MUST align with real word-by-word progression in the browser: WPM, speed modifiers, and word counting must match the emitted words, and timing work is not done until a browser-level word-sequence check proves it. - Reader and Learn tokenization MUST treat punctuation-only tokens such as commas, periods, and dashes as punctuation attached to nearby words or pauses, never as standalone counted words. - App-shell logo navigation MUST always lead to the main home/library screen; it must not deep-link into Go Live, Teleprompter, or another feature-specific route. +- Learn rehearsal speed MUST default to about 250 WPM and stay user-adjustable upward from that baseline; shipping a 300 WPM startup default is too aggressive. +- Go Live `ON AIR` badges and preview live dots MUST appear only while recording or streaming is actually active; idle selected or armed sources must stay visually non-live. - Learn and Teleprompter are separate screens with separate style ownership; do not bundle RSVP and teleprompter reader feature styles into one shared screen stylesheet or let one page inherit the other page's visual treatment. - User preferences persistence MUST sit behind a platform-agnostic user-settings abstraction, with browser storage implemented via local storage and room for other platform-specific implementations; theme, teleprompter layout preferences, camera/scene preferences, and similar saved settings belong there instead of ad-hoc feature stores. +- Streaming destination/platform configuration MUST be user-defined and persisted in settings; Settings and Go Live must not ship hardcoded platform instances, seeded destination accounts, or fixed fake provider rows beyond real runtime capabilities. +- Runtime screens must not keep inline seeded operational data, fake demo rows, or screen-local platform/source presets in page/component code; reusable labels and presets belong in shared contracts or catalogs, while rendered rows must come from persisted settings, workspace state, or live session state. - Build quality gates must stay green under `-warnaserror`. - GitHub Pages is the expected CI publish target for the standalone WebAssembly app; publish automation must keep the app browser-only and Pages-compatible. - GitHub Actions MUST keep separate, clearly named workflows for pull-request validation and release automation; vague workflow names are forbidden. @@ -365,6 +375,7 @@ Ask first: ### Dislikes - backend creep in the standalone runtime +- hardcoded fallback reader/test fixtures such as inline `Ready` chunks, fake word models, or synthetic UI state embedded directly in tests when the same behavior can be exercised through shared script fixtures, builders, or production-owned constants - agent-started local servers taking shared user ports or using ports outside the reserved `5050-5070` agent range - brittle selectors without `data-testid` - progress updates that imply a fix is done before there is concrete implementation and verification evidence; keep status factual and let the user verify final behavior personally diff --git a/design/app.js b/design/app.js index bbb0596..6c59c09 100644 --- a/design/app.js +++ b/design/app.js @@ -73,7 +73,7 @@ function updateAppHeader(screenId) { break; case 'rsvp': center.innerHTML = `${backBtn}Product LaunchIntro / Opening Block`; - right.innerHTML = `300 WPM${goLiveBtn}`; + right.innerHTML = `250 WPM${goLiveBtn}`; break; case 'teleprompter': center.innerHTML = `${backBtn}Product LaunchIntro · Opening Block`; @@ -93,7 +93,7 @@ function updateAppHeader(screenId) { // RSVP (Learn mode — simple word-by-word) // ============================================ -let rsvpSpeed = 300; +let rsvpSpeed = 250; let rsvpPlaying = true; function changeRsvpSpeed(delta) { diff --git a/design/rsvp.html b/design/rsvp.html index 9a17dad..c0c3a18 100644 --- a/design/rsvp.html +++ b/design/rsvp.html @@ -86,7 +86,7 @@
-
300WPM
+
250WPM
@@ -108,4 +108,4 @@ - \ No newline at end of file + diff --git a/docs/Features/GoLiveRuntime.md b/docs/Features/GoLiveRuntime.md index f2300f7..37c6d7f 100644 --- a/docs/Features/GoLiveRuntime.md +++ b/docs/Features/GoLiveRuntime.md @@ -7,19 +7,20 @@ The current page layout is a production-style studio surface: - the routed `Go Live` page owns its own studio chrome and suppresses the shared app header while the route is active, so `design/golive.html` remains the only topbar on that screen -- top session bar follows `design/golive.html`: back to Read, script title + session badge, centered session timer, panel toggles, mode switch, settings shortcut, REC, and the main stream action on the far right +- top session bar follows `design/golive.html`: back to Library, script title + session badge, centered session timer, panel toggles, mode switch, settings shortcut, REC, and the main stream action on the far right - the studio shell follows the same three-column grid as `design/golive.html`: a compact left input rail, a dominant center canvas/program stage, and a dedicated right operational rail - left input rail for scene cameras, add-camera action, utility sources, and microphone route status - center program stage for the selected program source and current script/session state - scene controls bar for scene chips, layout controls, transitions, and the primary `Take To Air` action - right rail for the current live preview plus compact stream, audio, and room/runtime panels +- source-card `ON AIR` badges and the preview red live dot only turn on when recording or streaming is actually active; idle routing and armed sources stay visually non-live - full-program mode collapses both side rails so the center canvas follows the design's focused monitor state -- destination cards that arm OBS, recording, LiveKit, and YouTube from persisted settings instead of editing credentials inline +- destination cards are built from persisted local outputs plus a dynamic browser-stored `ExternalDestinations` list; seeded fake provider rows are forbidden The runtime now owns real browser media outputs for the composed program scene and the current audio bus: - `Go Live` auto-seeds the first available browser camera into the scene when the scene is empty and the browser exposes a real camera list -- the center program stage always shows the currently selected scene camera, while the right preview rail shows the currently on-air camera until the operator takes the selected source live +- the center program stage always shows the currently selected scene camera, while the right preview rail shows the current program source and only marks it live once recording or streaming is active - `Go Live` builds one browser-side program stream from the scene camera cards by drawing the selected primary camera full-frame and then layering additional included cameras as positioned overlays on a canvas - the scene `AudioBus` is mixed into one program audio track through `AudioContext`, delay, and gain nodes before the final program stream is published or recorded - OBS browser output stays browser-only and exposes the composed program audio inside an OBS Browser Source environment @@ -34,7 +35,7 @@ Relay-only destinations stay configuration surfaces: - YouTube, Twitch, custom RTMP, and similar RTMP-style targets still persist credentials and routing in browser storage - `Go Live` only exposes quick arm/disarm toggles and readiness summaries for those targets - detailed destination credentials, ingest URLs, and provider-specific configuration live in `Settings` -- these targets do not publish directly from the browser runtime; they require an external relay or ingest layer outside this standalone WASM app +- these targets do not publish directly from the browser runtime; they require an external relay or ingest layer outside this standalone WASM app, and `Go Live` must not mark the session live unless a direct browser live output actually starts It is separate from: @@ -62,8 +63,8 @@ sequenceDiagram User->>Settings: Configure camera, FPS, mic, sync Settings->>Studio: Persist device preferences Settings->>Scene: Persist scene cameras and audio bus - User->>Settings: Configure LiveKit / YouTube / recording settings - Settings->>Studio: Persist provider credentials and destinations + User->>Settings: Add external destinations and configure provider credentials + Settings->>Studio: Persist local outputs and external destination list User->>GoLive: Open Go Live GoLive->>Studio: Load live routing settings GoLive->>Scene: Load current scene sources @@ -181,14 +182,16 @@ flowchart LR - `Settings` must expose a visible CTA into `Go Live` so device setup and live routing stay discoverable as separate flows. - the shared header shell must keep `Go Live` reachable from every non-`Go Live` routed page because it is a primary studio action - `Go Live` may arm multiple destinations at the same time. +- hardcoded destination instances are forbidden; the external destination list must come from persisted browser settings and may contain zero, one, or many platform entries - `Go Live` must reuse the browser-composed scene and not invent a separate media graph. - `Go Live` must auto-seed the first available browser camera into the scene when the scene is empty and devices are available. - `Go Live` must show the selected program source in the center monitor and the currently on-air source in the right preview rail until the operator explicitly takes the selected source live. +- `Go Live` must not render `ON AIR` source badges or red preview live dots while the session is idle; those indicators only represent active recording or streaming. - `Go Live` must show a stable empty preview state instead of mounting camera interop when the current scene has no cameras. - the routed `Go Live` page must not stack the shared app header above the studio topbar; the studio topbar is the only route chrome on that screen - any shared `Go Live` localized copy must come from `PrompterOne.Shared.Localization.UiTextCatalog`, so supported browser cultures localize the studio surface without feature-local string copies. - quick destination cards must only expose honest readiness summaries and arm/disarm toggles; fake in-page credential editors are forbidden on the operational studio surface -- legacy streaming settings must normalize to the current included program cameras so existing browser storage keeps working +- legacy streaming settings must normalize to the current included program cameras and migrate legacy provider fields into the canonical external destination list so existing browser storage keeps working - `VirtualCamera` mode normalizes to OBS armed by default, so browser sessions keep the legacy desktop-capture workflow unless the user explicitly turns OBS off - Camera source inclusion is persisted through `MediaSceneState`. - Destination credentials and endpoints are persisted only in browser storage for this standalone runtime. diff --git a/docs/Features/ReaderRuntime.md b/docs/Features/ReaderRuntime.md index 1fef02e..3d9cafa 100644 --- a/docs/Features/ReaderRuntime.md +++ b/docs/Features/ReaderRuntime.md @@ -16,6 +16,7 @@ The important contracts are: - Teleprompter pre-centers the next card before it slides in, so block transitions do not jump at the focal line. - Teleprompter block transitions always move in one upward direction: the outgoing card exits up and the incoming card rises from below. - Teleprompter controls stay readable at rest; they must not fade until they become unusable. +- Teleprompter route styles must already be present on first paint from the app host document; a late style attach during route entry is a regression. - Teleprompter user-adjusted font size, text width, focal position, and camera preference survive reloads through the shared user-settings contract. ## Flow @@ -54,10 +55,13 @@ flowchart LR - `teleprompter` forwards TPS pronunciation metadata to word-level `title` / `data-pronunciation` attributes. - `teleprompter` derives word-level pacing from the compiled TPS duration and carries effective WPM into the DOM for testable parity. - `teleprompter` preserves TPS front-matter speed offsets and `[normal]` resets when rebuilding reader blocks, so relative speed tags keep both their timing math and subtle word-level spacing cues. +- `teleprompter` applies TPS inline emotion colors only when a word is explicitly tagged; untagged reader words must stay on the base reader palette instead of inheriting an implicit `neutral` word class. - `teleprompter` keeps TPS inline colors visible even when a phrase group is active or the active word is highlighted. +- `teleprompter` keeps the active focus word calm: the active word may be brighter than its neighbors, but upcoming and read words stay gently dimmed and active-word glow stays restrained enough to avoid a bright moving patch. - `teleprompter` persists font scale, text width, focal point, and camera auto-start changes through `IUserSettingsStore` and restores them from stored `ReaderSettings` during bootstrap. - `teleprompter` prepositions the next card below the focal line before activation, so forward and backward block jumps both animate upward instead of alternating direction. - `teleprompter` uses one smooth paragraph realignment while words advance inside a card, but the first word of a newly entered card is already pre-centered so block changes do not trigger a second correction pass. +- `teleprompter` loads its feature stylesheet from the initial host `` instead of relying on route-time `HeadContent`, so direct opens and route transitions share the same first-paint styling. ## Verification @@ -73,5 +77,7 @@ flowchart LR - Playwright verifies there is no teleprompter overlay camera box and that phrase groups do not overflow. - Playwright verifies the teleprompter camera button attaches and detaches a real synthetic `MediaStream` on the background video layer. - Playwright verifies the full `Product Launch` teleprompter scenario, including visible controls, TPS formatting parity, screenshot artifacts, and aligned post-transition playback. +- Playwright verifies a dedicated reader-timing probe for both `learn` and `teleprompter`, recording emitted words in the browser and checking that sequence order and elapsed delays match the rendered timing contract word by word. +- Playwright verifies the teleprompter stylesheet is already registered in `document.styleSheets` before the app navigates into the teleprompter route. - Playwright verifies custom TPS speed offsets change computed teleprompter `letter-spacing` while `[normal]` words reset back to neutral spacing and timing. - Playwright verifies teleprompter width and focal settings survive a real browser reload and that backward block jumps keep the outgoing card on the upward exit path during the transition. diff --git a/src/PrompterOne.App/wwwroot/index.html b/src/PrompterOne.App/wwwroot/index.html index 6b3ad7e..ead7af9 100644 --- a/src/PrompterOne.App/wwwroot/index.html +++ b/src/PrompterOne.App/wwwroot/index.html @@ -13,6 +13,7 @@ + diff --git a/src/PrompterOne.Core/Streaming/Models/StreamingDestinationFieldIds.cs b/src/PrompterOne.Core/Streaming/Models/StreamingDestinationFieldIds.cs new file mode 100644 index 0000000..a770557 --- /dev/null +++ b/src/PrompterOne.Core/Streaming/Models/StreamingDestinationFieldIds.cs @@ -0,0 +1,12 @@ +namespace PrompterOne.Core.Models.Streaming; + +public static class StreamingDestinationFieldIds +{ + public const string Name = "name"; + public const string PublishUrl = "publish-url"; + public const string RoomName = "room-name"; + public const string RtmpUrl = "rtmp-url"; + public const string ServerUrl = "server-url"; + public const string StreamKey = "stream-key"; + public const string Token = "token"; +} diff --git a/src/PrompterOne.Core/Streaming/Models/StreamingProfile.cs b/src/PrompterOne.Core/Streaming/Models/StreamingProfile.cs index 572c394..9e65c2d 100644 --- a/src/PrompterOne.Core/Streaming/Models/StreamingProfile.cs +++ b/src/PrompterOne.Core/Streaming/Models/StreamingProfile.cs @@ -7,16 +7,35 @@ public enum StreamingProviderKind Rtmp } +public enum StreamingPlatformKind +{ + LiveKit, + VdoNinja, + Youtube, + Twitch, + CustomRtmp +} + public sealed record StreamingDestination( string Name, string Url, string? StreamKey = null, bool IsEnabled = true); +public sealed record StreamingPlatformDefinition( + StreamingPlatformKind Kind, + string IdPrefix, + string DisplayName, + StreamingProviderKind ProviderKind, + string DefaultProfileName, + string DefaultRtmpUrl = ""); + public sealed record StreamingProfile( string Id, string Name, StreamingProviderKind ProviderKind, + StreamingPlatformKind PlatformKind, + bool IsEnabled = false, string? ServerUrl = null, string? RoomName = null, string? Token = null, @@ -25,16 +44,12 @@ public sealed record StreamingProfile( bool MirrorLocalPreview = true) { public static StreamingProfile CreateDefault(StreamingProviderKind providerKind) => - new( - Id: providerKind.ToString().ToLowerInvariant(), - Name: providerKind switch - { - StreamingProviderKind.LiveKit => "LiveKit Stage", - StreamingProviderKind.VdoNinja => "VDO.Ninja Room", - _ => "RTMP Relay" - }, - ProviderKind: providerKind, - Destinations: Array.Empty()); + StreamingPlatformCatalog.CreateProfile(providerKind switch + { + StreamingProviderKind.LiveKit => StreamingPlatformKind.LiveKit, + StreamingProviderKind.VdoNinja => StreamingPlatformKind.VdoNinja, + _ => StreamingPlatformKind.CustomRtmp + }); } public sealed record StreamingPublishDescriptor( @@ -46,3 +61,125 @@ public sealed record StreamingPublishDescriptor( string Summary, IReadOnlyDictionary Parameters, string? LaunchUrl = null); + +public static class StreamingPlatformCatalog +{ + public static IReadOnlyList All { get; } = + [ + new( + StreamingPlatformKind.LiveKit, + "livekit", + "LiveKit", + StreamingProviderKind.LiveKit, + "LiveKit"), + new( + StreamingPlatformKind.VdoNinja, + "vdoninja", + "VDO.Ninja", + StreamingProviderKind.VdoNinja, + "VDO.Ninja"), + new( + StreamingPlatformKind.Youtube, + "youtube-live", + "YouTube Live", + StreamingProviderKind.Rtmp, + "YouTube Live", + "rtmps://a.rtmp.youtube.com/live2"), + new( + StreamingPlatformKind.Twitch, + "twitch-live", + "Twitch", + StreamingProviderKind.Rtmp, + "Twitch", + "rtmp://live.twitch.tv/app"), + new( + StreamingPlatformKind.CustomRtmp, + "custom-rtmp", + "Custom RTMP", + StreamingProviderKind.Rtmp, + "Custom RTMP") + ]; + + public static StreamingProfile CreateProfile( + StreamingPlatformKind kind, + IEnumerable? existingIds = null) + { + var definition = Get(kind); + var nextId = BuildNextId(definition.IdPrefix, existingIds ?? Array.Empty()); + return CreateProfile(kind, nextId); + } + + public static StreamingProfile CreateProfile(StreamingPlatformKind kind, string id) + { + var definition = Get(kind); + return new StreamingProfile( + Id: id, + Name: definition.DefaultProfileName, + ProviderKind: definition.ProviderKind, + PlatformKind: kind, + Destinations: definition.ProviderKind == StreamingProviderKind.Rtmp + ? [new StreamingDestination(definition.DefaultProfileName, definition.DefaultRtmpUrl)] + : Array.Empty()); + } + + public static StreamingPlatformDefinition Get(StreamingPlatformKind kind) => + All.First(definition => definition.Kind == kind); + + private static string BuildNextId(string prefix, IEnumerable existingIds) + { + var existingIdSet = existingIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .ToHashSet(StringComparer.Ordinal); + + if (!existingIdSet.Contains(prefix)) + { + return prefix; + } + + var suffix = 2; + while (existingIdSet.Contains($"{prefix}-{suffix}")) + { + suffix++; + } + + return $"{prefix}-{suffix}"; + } +} + +public static class StreamingProfileExtensions +{ + public static StreamingDestination GetPrimaryDestination(this StreamingProfile profile) + { + if (profile.Destinations is { Count: > 0 } destinations) + { + return destinations[0]; + } + + return new StreamingDestination(profile.Name, string.Empty); + } + + public static string GetPrimaryDestinationUrl(this StreamingProfile profile) => + profile.GetPrimaryDestination().Url; + + public static string GetPrimaryDestinationStreamKey(this StreamingProfile profile) => + profile.GetPrimaryDestination().StreamKey ?? string.Empty; + + public static StreamingProfile SetPrimaryDestination( + this StreamingProfile profile, + string name, + string url, + string streamKey) + { + return profile with + { + Name = name, + Destinations = + [ + new StreamingDestination( + string.IsNullOrWhiteSpace(name) ? profile.Name : name, + url, + streamKey) + ] + }; + } +} diff --git a/src/PrompterOne.Core/Streaming/Services/GoLiveDestinationRouting.cs b/src/PrompterOne.Core/Streaming/Services/GoLiveDestinationRouting.cs index 91399b8..8b40006 100644 --- a/src/PrompterOne.Core/Streaming/Services/GoLiveDestinationRouting.cs +++ b/src/PrompterOne.Core/Streaming/Services/GoLiveDestinationRouting.cs @@ -1,4 +1,5 @@ using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Streaming; using PrompterOne.Core.Models.Workspace; namespace PrompterOne.Core.Services.Streaming; @@ -71,7 +72,7 @@ private static IReadOnlyList NormalizeSelectio var existingSelections = (streaming.DestinationSourceSelections ?? Array.Empty()) .ToDictionary(selection => selection.TargetId, selection => selection.SourceIds, StringComparer.Ordinal); - return GoLiveTargetCatalog.AllTargetIds + return BuildAllTargetIds(streaming) .Select(targetId => new GoLiveDestinationSourceSelection( targetId, existingSelections.TryGetValue(targetId, out var existingSourceIds) @@ -80,6 +81,18 @@ private static IReadOnlyList NormalizeSelectio .ToArray(); } + private static IReadOnlyList BuildAllTargetIds(StreamStudioSettings streaming) + { + var externalTargetIds = (streaming.ExternalDestinations ?? Array.Empty()) + .Select(destination => destination.Id) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal); + + return GoLiveTargetCatalog.LocalTargetIds + .Concat(externalTargetIds) + .ToArray(); + } + private static IReadOnlyList BuildFallbackSourceIds(IReadOnlyList sceneCameras) { var included = sceneCameras diff --git a/src/PrompterOne.Core/Workspace/Models/GoLiveTargetCatalog.cs b/src/PrompterOne.Core/Workspace/Models/GoLiveTargetCatalog.cs index e69c687..5909db9 100644 --- a/src/PrompterOne.Core/Workspace/Models/GoLiveTargetCatalog.cs +++ b/src/PrompterOne.Core/Workspace/Models/GoLiveTargetCatalog.cs @@ -1,17 +1,23 @@ namespace PrompterOne.Core.Models.Workspace; +public sealed record GoLiveLocalTargetDefinition( + string Id, + string Name); + public static class GoLiveTargetCatalog { - public static IReadOnlyList AllTargetIds { get; } = + public static IReadOnlyList LocalTargetIds { get; } = [ TargetIds.Obs, TargetIds.Ndi, - TargetIds.Recording, - TargetIds.LiveKit, - TargetIds.VdoNinja, - TargetIds.Youtube, - TargetIds.Twitch, - TargetIds.CustomRtmp + TargetIds.Recording + ]; + + public static IReadOnlyList LocalTargets { get; } = + [ + new(TargetIds.Obs, TargetNames.Obs), + new(TargetIds.Ndi, TargetNames.Ndi), + new(TargetIds.Recording, TargetNames.Recording) ]; public static class TargetIds diff --git a/src/PrompterOne.Core/Workspace/Models/LearnSettings.cs b/src/PrompterOne.Core/Workspace/Models/LearnSettings.cs index 12bd272..f15d6de 100644 --- a/src/PrompterOne.Core/Workspace/Models/LearnSettings.cs +++ b/src/PrompterOne.Core/Workspace/Models/LearnSettings.cs @@ -1,9 +1,10 @@ namespace PrompterOne.Core.Models.Workspace; public sealed record LearnSettings( - int WordsPerMinute = 300, - int ContextWords = 2, - bool IgnoreScriptSpeeds = false, - bool AutoPlay = false, - bool LoopPlayback = false, - bool ShowPhrasePreview = true); + bool HasCustomizedWordsPerMinute = LearnSettingsDefaults.HasCustomizedWordsPerMinute, + int WordsPerMinute = LearnSettingsDefaults.WordsPerMinute, + int ContextWords = LearnSettingsDefaults.ContextWords, + bool IgnoreScriptSpeeds = LearnSettingsDefaults.IgnoreScriptSpeeds, + bool AutoPlay = LearnSettingsDefaults.AutoPlay, + bool LoopPlayback = LearnSettingsDefaults.LoopPlayback, + bool ShowPhrasePreview = LearnSettingsDefaults.ShowPhrasePreview); diff --git a/src/PrompterOne.Core/Workspace/Models/LearnSettingsDefaults.cs b/src/PrompterOne.Core/Workspace/Models/LearnSettingsDefaults.cs new file mode 100644 index 0000000..fd82973 --- /dev/null +++ b/src/PrompterOne.Core/Workspace/Models/LearnSettingsDefaults.cs @@ -0,0 +1,13 @@ +namespace PrompterOne.Core.Models.Workspace; + +public static class LearnSettingsDefaults +{ + public const bool HasCustomizedWordsPerMinute = false; + public const int LegacyWordsPerMinute = 300; + public const int WordsPerMinute = 250; + public const int ContextWords = 2; + public const bool IgnoreScriptSpeeds = false; + public const bool AutoPlay = false; + public const bool LoopPlayback = false; + public const bool ShowPhrasePreview = true; +} diff --git a/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs b/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs index 36c3103..5491df6 100644 --- a/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs +++ b/src/PrompterOne.Core/Workspace/Models/StudioSettings.cs @@ -1,3 +1,5 @@ +using PrompterOne.Core.Models.Streaming; + namespace PrompterOne.Core.Models.Workspace; public enum CameraResolutionPreset @@ -50,6 +52,7 @@ public sealed record StreamStudioSettings( int BitrateKbps = 6000, bool ShowTextOverlay = true, bool IncludeCameraInOutput = true, + IReadOnlyList? ExternalDestinations = null, IReadOnlyList? DestinationSourceSelections = null, string RtmpUrl = "", string StreamKey = "", diff --git a/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs b/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs index 0df2d58..5a68972 100644 --- a/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs +++ b/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs @@ -57,8 +57,15 @@ public async Task EnsureReadyAsync(CancellationToken cancellationToken = default var learnSettings = await _settingsStore.LoadAsync(BrowserAppSettingsKeys.LearnSettings, cancellationToken); if (learnSettings is not null) { + var normalizedLearnSettings = NormalizeLearnSettings(learnSettings); + if (normalizedLearnSettings != learnSettings) + { + _logger.LogInformation("Normalizing legacy learn settings from browser storage."); + await _settingsStore.SaveAsync(BrowserAppSettingsKeys.LearnSettings, normalizedLearnSettings, cancellationToken); + } + _logger.LogInformation("Restoring learn settings from browser storage."); - await _sessionService.UpdateLearnSettingsAsync(learnSettings); + await _sessionService.UpdateLearnSettingsAsync(normalizedLearnSettings); } var mediaScene = await _settingsStore.LoadAsync(BrowserAppSettingsKeys.SceneSettings, cancellationToken); @@ -76,4 +83,28 @@ public async Task EnsureReadyAsync(CancellationToken cancellationToken = default _gate.Release(); } } + + private static LearnSettings NormalizeLearnSettings(LearnSettings settings) + { + var normalizedWordsPerMinute = settings.HasCustomizedWordsPerMinute + ? NormalizeLearnWordsPerMinute(settings.WordsPerMinute, migrateLegacyDefault: false) + : NormalizeLearnWordsPerMinute(settings.WordsPerMinute, migrateLegacyDefault: true); + + return settings with { WordsPerMinute = normalizedWordsPerMinute }; + } + + private static int NormalizeLearnWordsPerMinute(int wordsPerMinute, bool migrateLegacyDefault) + { + if (wordsPerMinute <= 0) + { + return LearnSettingsDefaults.WordsPerMinute; + } + + if (migrateLegacyDefault && wordsPerMinute == LearnSettingsDefaults.LegacyWordsPerMinute) + { + return LearnSettingsDefaults.WordsPerMinute; + } + + return wordsPerMinute; + } } diff --git a/src/PrompterOne.Shared/AppShell/Services/GoLiveSessionState.cs b/src/PrompterOne.Shared/AppShell/Services/GoLiveSessionState.cs index de01cde..0c63272 100644 --- a/src/PrompterOne.Shared/AppShell/Services/GoLiveSessionState.cs +++ b/src/PrompterOne.Shared/AppShell/Services/GoLiveSessionState.cs @@ -113,15 +113,10 @@ public void SwitchToSelectedSource(IReadOnlyList sceneCameras }, publishToCrossTab: State.HasActiveSession); } - public void ToggleStream(IReadOnlyList sceneCameras) + public void StartStream(IReadOnlyList sceneCameras) { if (State.IsStreamActive) { - ApplyState(State with - { - IsStreamActive = false, - StreamStartedAt = null - }, publishToCrossTab: true); return; } @@ -133,15 +128,24 @@ public void ToggleStream(IReadOnlyList sceneCameras) }, publishToCrossTab: true); } - public void ToggleRecording(IReadOnlyList sceneCameras) + public void StopStream() + { + if (!State.IsStreamActive) + { + return; + } + + ApplyState(State with + { + IsStreamActive = false, + StreamStartedAt = null + }, publishToCrossTab: true); + } + + public void StartRecording(IReadOnlyList sceneCameras) { if (State.IsRecordingActive) { - ApplyState(State with - { - IsRecordingActive = false, - RecordingStartedAt = null - }, publishToCrossTab: true); return; } @@ -153,6 +157,42 @@ public void ToggleRecording(IReadOnlyList sceneCameras) }, publishToCrossTab: true); } + public void StopRecording() + { + if (!State.IsRecordingActive) + { + return; + } + + ApplyState(State with + { + IsRecordingActive = false, + RecordingStartedAt = null + }, publishToCrossTab: true); + } + + public void ToggleStream(IReadOnlyList sceneCameras) + { + if (State.IsStreamActive) + { + StopStream(); + return; + } + + StartStream(sceneCameras); + } + + public void ToggleRecording(IReadOnlyList sceneCameras) + { + if (State.IsRecordingActive) + { + StopRecording(); + return; + } + + StartRecording(sceneCameras); + } + public void SetState(GoLiveSessionState nextState) { ApplyState(nextState, publishToCrossTab: false); diff --git a/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs b/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs index 2accaae..3a89ed1 100644 --- a/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs +++ b/src/PrompterOne.Shared/AppShell/Services/PrompterOneServiceCollectionExtensions.cs @@ -74,6 +74,7 @@ public static IServiceCollection AddPrompterOneShared(this IServiceCollection se services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/src/PrompterOne.Shared/Contracts/UiTestIds.cs b/src/PrompterOne.Shared/Contracts/UiTestIds.cs index 45722c2..e5012d3 100644 --- a/src/PrompterOne.Shared/Contracts/UiTestIds.cs +++ b/src/PrompterOne.Shared/Contracts/UiTestIds.cs @@ -1,3 +1,5 @@ +using PrompterOne.Core.Models.Workspace; + namespace PrompterOne.Shared.Contracts; public static class UiTestIds @@ -264,6 +266,7 @@ public static class Settings public const string NoCameras = "settings-no-cameras"; public const string NoMics = "settings-no-mics"; public const string StreamingBitrate = "settings-streaming-bitrate"; + public const string StreamingDestinationAdd = "settings-streaming-destination-add"; public const string StreamingCustomRtmpKey = "settings-streaming-custom-rtmp-key"; public const string StreamingCustomRtmpName = "settings-streaming-custom-rtmp-name"; public const string StreamingCustomRtmpToggle = "settings-streaming-custom-rtmp-toggle"; @@ -335,6 +338,38 @@ public static class Settings public static string StreamingProviderSourceSummary(string providerId) => $"settings-streaming-provider-source-summary-{providerId}"; public static string StreamingProviderSourceToggle(string providerId, string sourceId) => $"settings-streaming-provider-source-{providerId}-{sourceId}"; + + public static string StreamingDestinationAddOption(string platformId) => $"settings-streaming-destination-add-{platformId}"; + + public static string StreamingDestinationField(string destinationId, string fieldId) => (destinationId, fieldId) switch + { + (GoLiveTargetCatalog.TargetIds.LiveKit, "server-url") => StreamingLiveKitServer, + (GoLiveTargetCatalog.TargetIds.LiveKit, "room-name") => StreamingLiveKitRoom, + (GoLiveTargetCatalog.TargetIds.LiveKit, "token") => StreamingLiveKitToken, + (GoLiveTargetCatalog.TargetIds.VdoNinja, "room-name") => StreamingVdoRoom, + (GoLiveTargetCatalog.TargetIds.VdoNinja, "publish-url") => StreamingVdoPublishUrl, + (GoLiveTargetCatalog.TargetIds.Youtube, "rtmp-url") => StreamingYoutubeUrl, + (GoLiveTargetCatalog.TargetIds.Youtube, "stream-key") => StreamingYoutubeKey, + (GoLiveTargetCatalog.TargetIds.Twitch, "rtmp-url") => StreamingTwitchUrl, + (GoLiveTargetCatalog.TargetIds.Twitch, "stream-key") => StreamingTwitchKey, + (GoLiveTargetCatalog.TargetIds.CustomRtmp, "name") => StreamingCustomRtmpName, + (GoLiveTargetCatalog.TargetIds.CustomRtmp, "rtmp-url") => StreamingCustomRtmpUrl, + (GoLiveTargetCatalog.TargetIds.CustomRtmp, "stream-key") => StreamingCustomRtmpKey, + _ => $"settings-streaming-destination-field-{destinationId}-{fieldId}" + }; + + public static string StreamingDestinationRemove(string destinationId) => + $"settings-streaming-destination-remove-{destinationId}"; + + public static string StreamingDestinationToggle(string destinationId) => destinationId switch + { + GoLiveTargetCatalog.TargetIds.LiveKit => StreamingLiveKitToggle, + GoLiveTargetCatalog.TargetIds.VdoNinja => StreamingVdoToggle, + GoLiveTargetCatalog.TargetIds.Youtube => StreamingYoutubeToggle, + GoLiveTargetCatalog.TargetIds.Twitch => StreamingTwitchToggle, + GoLiveTargetCatalog.TargetIds.CustomRtmp => StreamingCustomRtmpToggle, + _ => $"settings-streaming-destination-toggle-{destinationId}" + }; } public static class GoLive @@ -370,6 +405,7 @@ public static class GoLive public const string PreviewRail = "go-live-preview-rail"; public const string PreviewCard = "go-live-preview-card"; public const string PreviewEmpty = "go-live-preview-empty"; + public const string PreviewLiveDot = "go-live-preview-live-dot"; public const string PreviewSourceLabel = "go-live-preview-source-label"; public const string PreviewVideo = "go-live-preview-video"; public const string RecordingToggle = "go-live-recording-toggle"; @@ -406,6 +442,18 @@ public static class GoLive public const string YoutubeUrl = "go-live-youtube-url"; public static string SourceCameraSelect(string sourceId) => $"go-live-source-select-{sourceId}"; + public static string DestinationToggle(string destinationId) => destinationId switch + { + GoLiveTargetCatalog.TargetIds.Obs => ObsToggle, + GoLiveTargetCatalog.TargetIds.Ndi => NdiToggle, + GoLiveTargetCatalog.TargetIds.Recording => RecordingToggle, + GoLiveTargetCatalog.TargetIds.LiveKit => LiveKitToggle, + GoLiveTargetCatalog.TargetIds.VdoNinja => VdoToggle, + GoLiveTargetCatalog.TargetIds.Youtube => YoutubeToggle, + GoLiveTargetCatalog.TargetIds.Twitch => TwitchToggle, + GoLiveTargetCatalog.TargetIds.CustomRtmp => CustomRtmpToggle, + _ => $"go-live-destination-toggle-{destinationId}" + }; 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}"; @@ -415,6 +463,8 @@ public static class GoLive public static string SourceCameraAction(string deviceId) => $"go-live-source-camera-action-{deviceId}"; + public static string SourceCameraBadge(string sourceId) => $"go-live-source-badge-{sourceId}"; + public static string SourceVideo(string sourceId) => $"go-live-source-video-{sourceId}"; public static string AudioChannel(string channelId) => $"go-live-audio-channel-{channelId}"; diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor index ef6b9b3..d2222b5 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor @@ -28,7 +28,9 @@
- +
@@ -38,15 +40,23 @@ private const string DefaultTitle = "Preview"; private const string EmptyDescription = "Add a scene camera in Settings or include one in the live output to preview it here."; private const string EmptyTitle = "No camera"; + private const string IdleStateValue = "idle"; private const string LiveDotCssClass = "gl-air-dot-live"; [Parameter] public SceneCameraSource? Camera { get; set; } [Parameter] public bool CanSwitchProgram { get; set; } [Parameter] public string Description { get; set; } = DefaultDescription; + [Parameter] public string LiveState { get; set; } = IdleStateValue; [Parameter] public bool ShowSwitchAction { get; set; } [Parameter] public EventCallback SwitchSelectedSource { get; set; } [Parameter] public string SwitchActionLabel { get; set; } = string.Empty; [Parameter] public string Title { get; set; } = DefaultTitle; private bool HasCamera => Camera is not null; + + private string PreviewIndicatorState => ShowLiveDot + ? LiveState + : IdleStateValue; + + private bool ShowLiveDot => HasCamera && !string.Equals(LiveState, IdleStateValue, StringComparison.Ordinal); } diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor index 0bb17df..54cdd78 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor @@ -42,7 +42,9 @@ VideoClass="gl-source-feed" VideoTestId="@UiTestIds.GoLive.SourceVideo(source.SourceId)"> - @GetSourceBadge(source) + @GetSourceBadge(source) @@ -96,6 +98,7 @@ private const string PrompterUtilitySourceId = "prompter-display"; private const string RemoveLabel = "Remove"; private const string ScreenShareUtilitySourceId = "screen-share"; + private const string IdleStateValue = "idle"; private const string SourceIncludedLabel = "Armed for output"; private const string SourceLiveLabel = "Ready for canvas"; private const string SourceOnAirLabel = "On air"; @@ -105,6 +108,7 @@ [Parameter] public string? ActiveSourceId { get; set; } [Parameter] public bool CanAddCamera { get; set; } [Parameter] public bool HasPrimaryMicrophone { get; set; } + [Parameter] public string LiveState { get; set; } = IdleStateValue; [Parameter] public string MicrophoneName { get; set; } = string.Empty; [Parameter] public string MicrophoneRouteLabel { get; set; } = string.Empty; [Parameter] public EventCallback SelectSource { get; set; } @@ -117,6 +121,12 @@ private bool IsActive(SceneCameraSource source) => string.Equals(source.SourceId, ActiveSourceId, StringComparison.Ordinal); + private bool IsLiveSessionActive => + !string.Equals(LiveState, IdleStateValue, StringComparison.Ordinal); + + private bool IsOnAir(SceneCameraSource source) => + IsLiveSessionActive && IsActive(source); + private bool IsSelected(SceneCameraSource source) => string.Equals(source.SourceId, SelectedSourceId, StringComparison.Ordinal); @@ -134,7 +144,7 @@ private string GetSourceBadge(SceneCameraSource source) { - if (IsActive(source)) + if (IsOnAir(source)) { return SourceOnAirLabel; } @@ -146,10 +156,15 @@ : SourceSceneOnlyLabel; } + private string GetSourceIndicatorState(SceneCameraSource source) => + IsOnAir(source) + ? LiveState + : IdleStateValue; + private string GetSourceCardCssClass(SceneCameraSource source) { var classes = new List { "gl-cam-card" }; - if (IsActive(source)) + if (IsOnAir(source)) { classes.Add("gl-cam-onair"); } @@ -164,7 +179,7 @@ private string GetSourceBadgeCssClass(SceneCameraSource source) { var classes = new List { "gl-cam-status-badge" }; - classes.Add(IsActive(source) ? "gl-badge-onair" : "gl-badge-idle"); + classes.Add(IsOnAir(source) ? "gl-badge-onair" : "gl-badge-idle"); return string.Join(' ', classes); } diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor index 5a85db4..3160e7c 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor @@ -1,20 +1,21 @@ @namespace PrompterOne.Shared.Components.GoLive @using PrompterOne.Core.Models.Workspace +@using PrompterOne.Shared.GoLive.Models
+ @onclick="() => SelectTab.InvokeAsync(GoLiveStudioTab.Stream)">@GoLiveText.Sidebar.StreamTabLabel + @onclick="() => SelectTab.InvokeAsync(GoLiveStudioTab.Audio)">@GoLiveText.Sidebar.AudioTabLabel + @onclick="() => SelectTab.InvokeAsync(GoLiveStudioTab.Room)">@GoLiveText.Sidebar.RoomTabLabel
@@ -22,7 +23,7 @@ {
- @DestinationsLabel + @GoLiveText.Sidebar.DestinationsLabel
@foreach (var destination in Destinations) { @@ -30,7 +31,7 @@ data-testid="@UiTestIds.GoLive.ProviderCard(destination.Id)">
- @StatusLabel + @GoLiveText.Sidebar.StatusLabel
@foreach (var metric in StatusMetrics) { @@ -64,7 +65,7 @@
- @RuntimeLabel + @GoLiveText.Sidebar.RuntimeLabel
@foreach (var metric in RuntimeMetrics) { @@ -101,7 +102,7 @@ @channel.DetailLabel
-
@RoomTitle
-
@RoomDescription
+
@GoLiveText.Sidebar.RoomTitle
+
@GoLiveText.Sidebar.RoomDescription
} @@ -191,7 +192,7 @@
-
Live
+
@GoLiveText.Sidebar.LiveBadgeLabel
@RoomCode
@@ -226,7 +227,7 @@ - @MuteAllLabel + @GoLiveText.Sidebar.MuteAllLabel
- Guests + @GoLiveText.Sidebar.GuestsLabel @Participants.Count
@@ -288,18 +289,6 @@
@code { - private const string CreateRoomLabel = "Create Room"; - private const string CueLabel = "Cue"; - private const string DestinationsLabel = "Destinations"; - private const string InviteLabel = "Invite"; - private const string MicrophoneChannelId = "mic"; - private const string MuteAllLabel = "Mute All"; - private const string RoomDescription = "Invite remote guests to send their camera, mic, or screen. You control everything from here."; - private const string RoomTitle = "Remote Room"; - private const string RuntimeLabel = "Runtime"; - private const string StatusLabel = "Status"; - private const string TalkLabel = "Talk"; - [Parameter] public GoLiveStudioTab ActiveTab { get; set; } [Parameter] public IReadOnlyList AudioChannels { get; set; } = []; [Parameter] public EventCallback CreateRoom { get; set; } @@ -321,13 +310,4 @@ private bool _cueArmed => CueArmed; private bool _muteAllGuests => MuteAllGuests; private bool _talkbackEnabled => TalkbackEnabled; - - private static string GetToggleTestId(string targetId) => - targetId switch - { - var id when string.Equals(id, GoLiveTargetCatalog.TargetIds.Obs, StringComparison.Ordinal) => UiTestIds.GoLive.ObsToggle, - var id when string.Equals(id, GoLiveTargetCatalog.TargetIds.Recording, StringComparison.Ordinal) => UiTestIds.GoLive.RecordingToggle, - var id when string.Equals(id, GoLiveTargetCatalog.TargetIds.LiveKit, StringComparison.Ordinal) => UiTestIds.GoLive.LiveKitToggle, - _ => UiTestIds.GoLive.YoutubeToggle - }; } diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor.css b/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor.css index a4bf979..ea1ad0b 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor.css +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveStudioSidebar.razor.css @@ -113,6 +113,10 @@ background: #ff4444; } +.gl-dest-platform-dot.twitch { + background: #9146ff; +} + .gl-dest-platform-dot.livekit { background: #60a5fa; } @@ -121,6 +125,10 @@ background: #8b96a6; } +.gl-dest-platform-dot.relay { + background: #c4a060; +} + .gl-dest-platform-dot.recording { background: #ff8d8d; } diff --git a/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs b/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs new file mode 100644 index 0000000..3c49e66 --- /dev/null +++ b/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs @@ -0,0 +1,156 @@ +namespace PrompterOne.Shared.GoLive.Models; + +public static class GoLiveText +{ + public static class Chrome + { + public const string BackLabel = "Back"; + public const string DirectorModeLabel = "Director"; + public const string LivePreviewTitle = "Live"; + public const string StreamingSubtitle = "Program routing"; + public const string StudioModeLabel = "Studio"; + } + + public static class Load + { + public const string LoadMessage = "Unable to prepare live routing right now."; + public const string LoadOperation = "Go Live load"; + public const string SaveSceneMessage = "Unable to save the current live scene."; + public const string SaveSceneOperation = "Go Live save scene"; + public const string SaveStudioMessage = "Unable to save live routing settings."; + public const string SaveStudioOperation = "Go Live save studio"; + } + + public static class Audio + { + public const string DefaultMicrophoneRouteLabel = "Monitor + Stream"; + public const string MonitorOnlyLabel = "Monitor only"; + public const string NoMicrophoneLabel = "No microphone"; + public const string StreamOnlyLabel = "Stream only"; + } + + public static class Session + { + public const string CameraFallbackLabel = "No camera selected"; + public const string DefaultProgramTimerLabel = "00:00:00"; + public const string IdleStateValue = "idle"; + public const string ProgramBadgeIdleLabel = "Ready"; + public const string ProgramBadgeLiveLabel = "Live"; + public const string ProgramBadgeRecordingLabel = "Rec"; + public const string ProgramBadgeStreamingRecordingLabel = "Live + Rec"; + public const string RecordingIndicatorLabel = "REC"; + public const string RecordingStateValue = "recording"; + public const string SessionIdleLabel = "Ready"; + public const string SessionRecordingLabel = "Recording"; + public const string SessionStreamingLabel = "Streaming"; + public const string SessionStreamingRecordingLabel = "Streaming + Recording"; + public const string StartStreamMessage = "Unable to start live outputs right now."; + public const string StartStreamOperation = "Go Live start stream"; + public const string StageFrameRate30Label = "30 FPS"; + public const string StageFrameRate60Label = "60 FPS"; + public const string StopStreamMessage = "Unable to stop live outputs right now."; + public const string StopStreamOperation = "Go Live stop stream"; + public const string StreamingStateValue = "streaming"; + public const string StreamButtonLabel = "Start Stream"; + public const string StreamPrerequisiteDetail = "No direct browser live output is armed. RTMP destinations still require an external relay."; + public const string StreamPrerequisiteMessage = "Arm OBS browser output or a direct browser publishing destination before starting stream."; + public const string StreamPrerequisiteOperation = "Go Live stream prerequisites"; + public const string StreamStopLabel = "Stop Stream"; + public const string SwitchProgramMessage = "Unable to switch the live program source right now."; + public const string SwitchProgramOperation = "Go Live switch program source"; + public const string SwitchButtonDisabledLabel = "On Program"; + public const string SwitchButtonLabel = "Switch"; + public const string StartRecordingMessage = "Unable to start recording right now."; + public const string StartRecordingOperation = "Go Live start recording"; + public const string StopRecordingMessage = "Unable to stop recording right now."; + public const string StopRecordingOperation = "Go Live stop recording"; + } + + public static class Surface + { + public const string ActiveDestinationsMetricLabel = "Destinations"; + public const string AudioIdleDetailLabel = "Idle"; + public const string AudioMicChannelId = "mic"; + public const string AudioProgramChannelId = "program"; + public const string AudioProgramChannelLabel = "Program"; + public const string AudioRecordingChannelId = "recording"; + public const string AudioRecordingChannelLabel = "Recording"; + public const string CustomScenePrefix = "custom-scene-"; + public const string CustomSceneTitlePrefix = "Scene "; + public const string DetailLocalProgramLabel = "Local program"; + public const string DirectorSourcesTitle = "Cameras"; + public const string HostParticipantId = "host"; + public const string HostParticipantInitial = "H"; + public const string HostParticipantName = "Host"; + public const string InterviewSceneFallback = "Interview"; + public const string LocalRoomPrefix = "local-"; + public const string MainSceneFallback = "Camera 1"; + public const string MicrophoneMetricLabel = "Mic"; + public const string NoScriptProgressLabel = "No script loaded"; + public const string PictureInPictureSceneId = "scene-picture-in-picture"; + public const string PictureInPictureSceneLabel = "PiP Slides"; + public const string PrimarySceneId = "scene-primary"; + public const string ProgramMetricLabel = "Camera"; + public const string ProgramStandbyDetailLabel = "Program idle"; + public const string RecordingActiveMetricValue = "Saving"; + public const string RecordingMetricLabel = "Recording"; + public const string RecordingReadyDetailLabel = "Ready"; + public const string RecordingReadyMetricValue = "Armed"; + public const string ResolutionDimensionsFullHd = "1920 × 1080"; + public const string ResolutionDimensionsHd = "1280 × 720"; + public const string ResolutionDimensionsUltraHd = "3840 × 2160"; + public const string RoomCodeFallback = "local-studio"; + public const string RuntimeEngineIdleValue = "Idle"; + public const string RuntimeEngineLabel = "Runtime"; + public const string RuntimeEngineLiveKitValue = "LiveKit"; + public const string RuntimeEngineObsBrowserValue = "OBS browser"; + public const string RuntimeEngineObsLiveKitValue = "OBS + LiveKit"; + public const string RuntimeEngineRecorderValue = "Recorder"; + public const string SceneSlidesId = "scene-slides"; + public const string SceneSlidesLabel = "Slides"; + public const string SecondarySceneId = "scene-secondary"; + public const string SessionMetricLabel = "Session"; + public const string SourcesTitle = "Sources"; + public const string StatusBitrateLabel = "Bitrate"; + public const string StatusOutputLabel = "Output"; + public const string StreamFormatFullHd30 = "1080p30"; + public const string StreamFormatFullHd60 = "1080p60"; + public const string StreamFormatHd30 = "720p30"; + public const string StreamFormatUltraHd30 = "2160p30"; + } + + public static class Destination + { + public const string DisabledSummary = "Disabled in this live session."; + public const string DisabledStatusLabel = "Disabled"; + public const string EnabledStatusLabel = "Ready"; + public const string LocalSummarySuffix = " source(s) armed for this output."; + public const string NeedsSetupStatusLabel = "Needs setup"; + public const string NoSourceSummary = "No routed source is armed for this destination yet."; + public const string PrimaryChannelPlatformLabel = "Primary channel"; + public const string RecordingTone = "recording"; + public const string RelayStatusLabel = "Relay"; + public const string SettingsPlatformLabel = "Settings preset"; + public const string LocalTone = "local"; + } + + public static class Sidebar + { + public const string AudioTabLabel = "Audio"; + public const string CreateRoomLabel = "Create Room"; + public const string CueLabel = "Cue"; + public const string DestinationsLabel = "Destinations"; + public const string GuestsLabel = "Guests"; + public const string InviteLabel = "Invite"; + public const string LiveBadgeLabel = "Live"; + public const string MicrophoneChannelId = "mic"; + public const string MuteAllLabel = "Mute All"; + public const string RoomDescription = "Invite remote guests to send their camera, mic, or screen. You control everything from here."; + public const string RoomTabLabel = "Room"; + public const string RoomTitle = "Remote Room"; + public const string RuntimeLabel = "Runtime"; + public const string StatusLabel = "Status"; + public const string StreamTabLabel = "Stream"; + public const string TalkLabel = "Talk"; + } +} diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs index 44033ae..067835d 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs @@ -1,5 +1,6 @@ using PrompterOne.Core.Models.Media; using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.GoLive.Models; using PrompterOne.Shared.Services; using PrompterOne.Shared.Settings.Models; using PrompterOne.Shared.Storage; @@ -23,8 +24,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private async Task BootstrapPageAsync() { await Diagnostics.RunAsync( - GoLiveLoadOperation, - GoLiveLoadMessage, + GoLiveText.Load.LoadOperation, + GoLiveText.Load.LoadMessage, async () => { await Bootstrapper.EnsureReadyAsync(); @@ -148,7 +149,7 @@ private void UpdateScreenMetadata() _screenTitle = SessionService.State.Title; _screenSubtitle = SessionService.State.PreviewSegments.Count > 0 ? SessionService.State.PreviewSegments[0].Title - : StreamingSubtitle; + : GoLiveText.Chrome.StreamingSubtitle; SyncGoLiveSessionState(); EnsureStudioSurfaceState(); Shell.ShowGoLive(_screenTitle, _screenSubtitle, SessionService.State.ScriptId); @@ -157,8 +158,8 @@ private void UpdateScreenMetadata() private async Task PersistSceneAsync() { await Diagnostics.RunAsync( - GoLiveSceneOperation, - GoLiveSceneMessage, + GoLiveText.Load.SaveSceneOperation, + GoLiveText.Load.SaveSceneMessage, () => SettingsStore.SaveAsync(BrowserAppSettingsKeys.SceneSettings, MediaSceneService.State)); SyncGoLiveSessionState(); } @@ -166,8 +167,8 @@ await Diagnostics.RunAsync( private async Task PersistStudioSettingsAsync() { await Diagnostics.RunAsync( - GoLiveStudioOperation, - GoLiveStudioMessage, + GoLiveText.Load.SaveStudioOperation, + GoLiveText.Load.SaveStudioMessage, () => StudioSettingsStore.SaveAsync(_studioSettings)); SyncGoLiveSessionState(); } diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Descriptors.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Descriptors.cs index dee8636..083e08f 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Descriptors.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Descriptors.cs @@ -1,4 +1,5 @@ using PrompterOne.Core.Models.Media; +using PrompterOne.Shared.GoLive.Models; namespace PrompterOne.Shared.Pages; @@ -7,9 +8,9 @@ public partial class GoLivePage private static string FormatRouteTarget(AudioRouteTarget routeTarget) => routeTarget switch { - AudioRouteTarget.Monitor => "Monitor only", - AudioRouteTarget.Stream => "Stream only", - _ => DefaultMicRouteLabel + AudioRouteTarget.Monitor => GoLiveText.Audio.MonitorOnlyLabel, + AudioRouteTarget.Stream => GoLiveText.Audio.StreamOnlyLabel, + _ => GoLiveText.Audio.DefaultMicrophoneRouteLabel }; private sealed record GoLiveDestinationState( diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Destinations.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Destinations.cs index 566e47a..e53100f 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Destinations.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Destinations.cs @@ -1,136 +1,228 @@ using System.Globalization; +using PrompterOne.Core.Models.Streaming; using PrompterOne.Core.Models.Workspace; using PrompterOne.Core.Services.Streaming; +using PrompterOne.Shared.Components.GoLive; +using PrompterOne.Shared.GoLive.Models; +using PrompterOne.Shared.Services; namespace PrompterOne.Shared.Pages; public partial class GoLivePage { - private const string DestinationDisabledSummary = "Disabled in this live session."; - private const string DestinationDisabledStatusLabel = "Disabled"; - private const string DestinationEnabledStatusLabel = "Ready"; - private const string DestinationLocalSummarySuffix = " source(s) armed for this output."; - private const string DestinationNeedsSetupStatusLabel = "Needs setup"; - private const string DestinationNoSourceSummary = "No routed source is armed for this destination yet."; - private const string DestinationRemoteReadySummary = "Credentials and source routing are ready in Settings."; - private const string DestinationRemoteSetupSummary = "Complete provider setup in Settings before going live."; - private IReadOnlyList GetSelectedSourceIds(string targetId) => GoLiveDestinationRouting.GetSelectedSourceIds(_studioSettings.Streaming, targetId, SceneCameras); - private bool BuildDestinationIsReady(bool isEnabled, string targetId, params string[] requiredValues) + private IReadOnlyList ExternalDestinations => + _studioSettings.Streaming.ExternalDestinations ?? Array.Empty(); + + private bool BuildDestinationIsReady(bool isEnabled, string targetId) { if (!isEnabled) { return false; } - if (GetSelectedSourceIds(targetId).Count == 0) + return GetSelectedSourceIds(targetId).Count > 0; + } + + private bool BuildExternalDestinationIsReady(StreamingProfile destination, StreamingPublishDescriptor descriptor) + { + if (!destination.IsEnabled || !descriptor.IsReady) { return false; } - return requiredValues.All(value => !string.IsNullOrWhiteSpace(value)); + return GetSelectedSourceIds(destination.Id).Count > 0; } private string BuildLocalSummary(string targetId) { var selectedSources = GetSelectedSourceIds(targetId); return selectedSources.Count == 0 - ? DestinationNoSourceSummary + ? GoLiveText.Destination.NoSourceSummary : string.Concat( selectedSources.Count.ToString(CultureInfo.InvariantCulture), - DestinationLocalSummarySuffix); + GoLiveText.Destination.LocalSummarySuffix); } - private string BuildRemoteSummary(bool isEnabled, string targetId, params string[] requiredValues) + private string BuildExternalSummary(StreamingProfile destination, StreamingPublishDescriptor descriptor) { - if (!isEnabled) + if (!destination.IsEnabled) { - return DestinationDisabledSummary; + return GoLiveText.Destination.DisabledSummary; } - if (GetSelectedSourceIds(targetId).Count == 0) + if (GetSelectedSourceIds(destination.Id).Count == 0) { - return DestinationNoSourceSummary; + return GoLiveText.Destination.NoSourceSummary; } - return requiredValues.All(value => !string.IsNullOrWhiteSpace(value)) - ? DestinationRemoteReadySummary - : DestinationRemoteSetupSummary; + return descriptor.Summary; } - private string BuildTargetStatusLabel(bool isEnabled, string targetId, params string[] requiredValues) + private string BuildTargetStatusLabel(bool isEnabled, string targetId) { if (!isEnabled) { - return DestinationDisabledStatusLabel; + return GoLiveText.Destination.DisabledStatusLabel; } - return BuildDestinationIsReady(isEnabled, targetId, requiredValues) - ? DestinationEnabledStatusLabel - : DestinationNeedsSetupStatusLabel; + return BuildDestinationIsReady(isEnabled, targetId) + ? GoLiveText.Destination.EnabledStatusLabel + : GoLiveText.Destination.NeedsSetupStatusLabel; } - private async Task ToggleObsOutputAsync() + private string BuildExternalTargetStatusLabel(StreamingProfile destination, StreamingPublishDescriptor descriptor) { - await RunSerializedInteractionAsync(async () => + if (!destination.IsEnabled) { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with - { - ObsVirtualCameraEnabled = !_studioSettings.Streaming.ObsVirtualCameraEnabled, - OutputMode = StreamingOutputMode.VirtualCamera - } - }; - - await PersistStudioSettingsAsync(); - }); + return GoLiveText.Destination.DisabledStatusLabel; + } + + if (!BuildExternalDestinationIsReady(destination, descriptor)) + { + return GoLiveText.Destination.NeedsSetupStatusLabel; + } + + return descriptor.RequiresExternalRelay + ? GoLiveText.Destination.RelayStatusLabel + : GoLiveText.Destination.EnabledStatusLabel; } - private async Task ToggleRecordingOutputAsync() + private IReadOnlyList BuildDestinationSummary() { - await EnsurePageReadyAsync(); + var localTargets = GoLiveTargetCatalog.LocalTargets + .Select(BuildLocalDestinationSummary); + var externalTargets = ExternalDestinations + .Select(BuildExternalDestinationSummary); + + return localTargets + .Concat(externalTargets) + .ToArray(); + } - _studioSettings = _studioSettings with + private GoLiveDestinationSummaryViewModel BuildLocalDestinationSummary(GoLiveLocalTargetDefinition target) + { + var isEnabled = IsLocalTargetEnabled(target.Id); + var isReady = BuildDestinationIsReady(isEnabled, target.Id); + + return new GoLiveDestinationSummaryViewModel( + target.Id, + target.Name, + BuildLocalPlatformLabel(target.Id), + isEnabled, + isReady, + BuildLocalSummary(target.Id), + BuildTargetStatusLabel(isEnabled, target.Id), + BuildLocalTargetTone(target.Id)); + } + + private GoLiveDestinationSummaryViewModel BuildExternalDestinationSummary(StreamingProfile destination) + { + var descriptor = StreamingDescriptorResolver.Describe(destination); + var presentation = StreamingPlatformPresentationCatalog.Get(destination.PlatformKind); + + return new GoLiveDestinationSummaryViewModel( + destination.Id, + destination.Name, + presentation.GoLivePlatformLabel, + destination.IsEnabled, + BuildExternalDestinationIsReady(destination, descriptor), + BuildExternalSummary(destination, descriptor), + BuildExternalTargetStatusLabel(destination, descriptor), + presentation.Tone); + } + + private static string BuildLocalPlatformLabel(string targetId) => + string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Recording, StringComparison.Ordinal) + ? GoLiveText.Destination.PrimaryChannelPlatformLabel + : GoLiveText.Destination.SettingsPlatformLabel; + + private static string BuildLocalTargetTone(string targetId) => + string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Recording, StringComparison.Ordinal) + ? GoLiveText.Destination.RecordingTone + : GoLiveText.Destination.LocalTone; + + private bool IsLocalTargetEnabled(string targetId) => targetId switch + { + GoLiveTargetCatalog.TargetIds.Obs => _studioSettings.Streaming.ObsVirtualCameraEnabled, + GoLiveTargetCatalog.TargetIds.Ndi => _studioSettings.Streaming.NdiOutputEnabled, + GoLiveTargetCatalog.TargetIds.Recording => _studioSettings.Streaming.LocalRecordingEnabled, + _ => false + }; + + private StreamingProfile? ResolvePrimaryRoomDestination() + { + return ExternalDestinations.FirstOrDefault(destination => + destination.IsEnabled + && destination.ProviderKind == StreamingProviderKind.LiveKit + && (!string.IsNullOrWhiteSpace(destination.RoomName) + || !string.IsNullOrWhiteSpace(destination.ServerUrl) + || !string.IsNullOrWhiteSpace(destination.Token))) + ?? ExternalDestinations.FirstOrDefault(destination => + destination.IsEnabled + && destination.ProviderKind == StreamingProviderKind.VdoNinja + && (!string.IsNullOrWhiteSpace(destination.PublishUrl) + || !string.IsNullOrWhiteSpace(destination.RoomName))); + } + + private async Task ToggleDestinationSummaryAsync(string targetId) + { + if (GoLiveTargetCatalog.LocalTargetIds.Contains(targetId, StringComparer.Ordinal)) { - Streaming = _studioSettings.Streaming with - { - LocalRecordingEnabled = !_studioSettings.Streaming.LocalRecordingEnabled, - OutputMode = StreamingOutputMode.LocalRecording - } - }; + await ToggleLocalDestinationAsync(targetId); + return; + } - await PersistStudioSettingsAsync(); + await UpdateGoLiveStreamingSettingsAsync(streaming => streaming with + { + ExternalDestinations = ExternalDestinations + .Select(destination => string.Equals(destination.Id, targetId, StringComparison.Ordinal) + ? destination with { IsEnabled = !destination.IsEnabled } + : destination) + .ToArray() + }); } - private async Task ToggleLiveKitSettingsAsync() + private async Task ToggleLocalDestinationAsync(string targetId) { - await EnsurePageReadyAsync(); + if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Obs, StringComparison.Ordinal)) + { + await EnsurePageReadyAsync(); + await UpdateGoLiveStreamingSettingsAsync(streaming => streaming with + { + ObsVirtualCameraEnabled = !streaming.ObsVirtualCameraEnabled, + OutputMode = StreamingOutputMode.VirtualCamera + }); + return; + } - _studioSettings = _studioSettings with + if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Ndi, StringComparison.Ordinal)) { - Streaming = _studioSettings.Streaming with + await EnsurePageReadyAsync(); + await UpdateGoLiveStreamingSettingsAsync(streaming => streaming with { - LiveKitEnabled = !_studioSettings.Streaming.LiveKitEnabled - } - }; + NdiOutputEnabled = !streaming.NdiOutputEnabled, + OutputMode = StreamingOutputMode.NdiOutput + }); + return; + } - await PersistStudioSettingsAsync(); + await EnsurePageReadyAsync(); + await UpdateGoLiveStreamingSettingsAsync(streaming => streaming with + { + LocalRecordingEnabled = !streaming.LocalRecordingEnabled, + OutputMode = StreamingOutputMode.LocalRecording + }); } - private async Task ToggleYoutubeSettingsAsync() + private async Task UpdateGoLiveStreamingSettingsAsync(Func update) { await EnsurePageReadyAsync(); - _studioSettings = _studioSettings with { - Streaming = _studioSettings.Streaming with - { - YoutubeEnabled = !_studioSettings.Streaming.YoutubeEnabled, - OutputMode = StreamingOutputMode.DirectRtmp - } + Streaming = GoLiveDestinationRouting.Normalize(update(_studioSettings.Streaming), SceneCameras) }; await PersistStudioSettingsAsync(); diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs index 911e982..8296e48 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs @@ -5,13 +5,6 @@ namespace PrompterOne.Shared.Pages; public partial class GoLivePage { - private const string GoLiveStartStreamMessage = "Unable to start live outputs right now."; - private const string GoLiveStartStreamOperation = "Go Live start stream"; - private const string GoLiveStopStreamMessage = "Unable to stop live outputs right now."; - private const string GoLiveStopStreamOperation = "Go Live stop stream"; - private const string GoLiveSwitchProgramMessage = "Unable to switch the live program source right now."; - private const string GoLiveSwitchProgramOperation = "Go Live switch program source"; - private GoLiveOutputRuntimeRequest BuildRuntimeRequest(SceneCameraSource? camera) => GoLiveOutputRequestFactory.Build( camera, diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs index 13057e2..a1f2816 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs @@ -1,47 +1,27 @@ using System.Globalization; using PrompterOne.Core.Models.Media; using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.GoLive.Models; namespace PrompterOne.Shared.Pages; public partial class GoLivePage { - private const string CameraFallbackLabel = "No camera selected"; - private const string DefaultProgramTimerLabel = "00:00:00"; - private const string GoLiveStartRecordingMessage = "Unable to start recording right now."; - private const string GoLiveStartRecordingOperation = "Go Live start recording"; - private const string GoLiveStopRecordingMessage = "Unable to stop recording right now."; - private const string GoLiveStopRecordingOperation = "Go Live stop recording"; - private const string ProgramBadgeIdleLabel = "Ready"; - private const string ProgramBadgeLiveLabel = "Live"; - private const string ProgramBadgeRecordingLabel = "Rec"; - private const string ProgramBadgeStreamingRecordingLabel = "Live + Rec"; - private const string RecordingIndicatorLabel = "REC"; - private const string SessionIdleLabel = "Ready"; private const string SessionBadgeIdleCssClass = "gl-badge-idle"; private const string SessionBadgeLiveCssClass = "gl-badge-live"; private const string SessionBadgeRecordingCssClass = "gl-badge-rec"; - private const string SessionRecordingLabel = "Recording"; - private const string SessionStreamingLabel = "Streaming"; - private const string SessionStreamingRecordingLabel = "Streaming + Recording"; - private const string StageFrameRate30Label = "30 FPS"; - private const string StageFrameRate60Label = "60 FPS"; - private const string StreamButtonLabel = "Start Stream"; - private const string StreamStopLabel = "Stop Stream"; - private const string SwitchButtonDisabledLabel = "On Program"; - private const string SwitchButtonLabel = "Switch"; private SceneCameraSource? ActiveCamera => ResolveSessionSource(GoLiveSession.State.ActiveSourceId) ?? PreviewCamera; private string ActiveSessionLabel => (GoLiveSession.State.IsStreamActive, GoLiveSession.State.IsRecordingActive) switch { - (true, true) => SessionStreamingRecordingLabel, - (true, false) => SessionStreamingLabel, - (false, true) => SessionRecordingLabel, - _ => SessionIdleLabel + (true, true) => GoLiveText.Session.SessionStreamingRecordingLabel, + (true, false) => GoLiveText.Session.SessionStreamingLabel, + (false, true) => GoLiveText.Session.SessionRecordingLabel, + _ => GoLiveText.Session.SessionIdleLabel }; - private string ActiveSourceLabel => ActiveCamera?.Label ?? CameraFallbackLabel; + private string ActiveSourceLabel => ActiveCamera?.Label ?? GoLiveText.Session.CameraFallbackLabel; private bool CanControlProgram => SelectedCamera is not null; @@ -49,14 +29,22 @@ public partial class GoLivePage && SelectedCamera.Transform.Visible && !string.Equals(SelectedCamera.SourceId, ActiveCamera?.SourceId, StringComparison.Ordinal); - private bool IsLiveSessionActive => GoLiveSession.State.IsStreamActive || GoLiveSession.State.IsRecordingActive; + private bool IsLiveSessionActive => !string.Equals(SessionIndicatorState, GoLiveText.Session.IdleStateValue, StringComparison.Ordinal); + + private string SessionIndicatorState => (GoLiveSession.State.IsStreamActive, GoLiveSession.State.IsRecordingActive) switch + { + (true, true) => GoLiveText.Session.RecordingStateValue, + (true, false) => GoLiveText.Session.StreamingStateValue, + (false, true) => GoLiveText.Session.RecordingStateValue, + _ => GoLiveText.Session.IdleStateValue + }; private string PrimarySessionBadge => (GoLiveSession.State.IsStreamActive, GoLiveSession.State.IsRecordingActive) switch { - (true, true) => ProgramBadgeStreamingRecordingLabel, - (true, false) => ProgramBadgeLiveLabel, - (false, true) => ProgramBadgeRecordingLabel, - _ => ProgramBadgeIdleLabel + (true, true) => GoLiveText.Session.ProgramBadgeStreamingRecordingLabel, + (true, false) => GoLiveText.Session.ProgramBadgeLiveLabel, + (false, true) => GoLiveText.Session.ProgramBadgeRecordingLabel, + _ => GoLiveText.Session.ProgramBadgeIdleLabel }; private string ProgramResolutionLabel => $"{ResolveResolutionDimensions(_studioSettings.Streaming.OutputResolution)} • {ActiveSourceLabel}"; @@ -70,17 +58,21 @@ public partial class GoLivePage _ => SessionBadgeIdleCssClass }; - private string SelectedSourceLabel => SelectedCamera?.Label ?? CameraFallbackLabel; + private string SelectedSourceLabel => SelectedCamera?.Label ?? GoLiveText.Session.CameraFallbackLabel; private SceneCameraSource? SelectedCamera => ResolveSessionSource(GoLiveSession.State.SelectedSourceId) ?? ActiveCamera; private string StageFrameRateLabel => BuildStageFrameRateLabel(_studioSettings.Streaming.OutputResolution); - private string StreamActionLabel => GoLiveSession.State.IsStreamActive ? StreamStopLabel : StreamButtonLabel; + private string StreamActionLabel => GoLiveSession.State.IsStreamActive + ? GoLiveText.Session.StreamStopLabel + : GoLiveText.Session.StreamButtonLabel; private string StreamButtonDisplayText => StreamActionLabel.ToUpperInvariant(); - private string SwitchActionLabel => CanSwitchProgram ? SwitchButtonLabel : SwitchButtonDisabledLabel; + private string SwitchActionLabel => CanSwitchProgram + ? GoLiveText.Session.SwitchButtonLabel + : GoLiveText.Session.SwitchButtonDisabledLabel; private string BitrateTelemetry => $"{_studioSettings.Streaming.BitrateKbps} kbps"; @@ -115,8 +107,8 @@ private async Task SwitchSelectedSourceAsync() await EnsurePageReadyAsync(); await EnsureSelectedCameraReadyForProgramAsync(); await Diagnostics.RunAsync( - GoLiveSwitchProgramOperation, - GoLiveSwitchProgramMessage, + GoLiveText.Session.SwitchProgramOperation, + GoLiveText.Session.SwitchProgramMessage, async () => { var nextCamera = SelectedCamera; @@ -136,24 +128,34 @@ await RunSerializedInteractionAsync(async () => if (GoLiveSession.State.IsStreamActive) { await Diagnostics.RunAsync( - GoLiveStopStreamOperation, - GoLiveStopStreamMessage, + GoLiveText.Session.StopStreamOperation, + GoLiveText.Session.StopStreamMessage, async () => { await GoLiveOutputRuntime.StopStreamAsync(); - GoLiveSession.ToggleStream(SceneCameras); + GoLiveSession.StopStream(); }); return; } await EnsureSelectedCameraReadyForProgramAsync(); await Diagnostics.RunAsync( - GoLiveStartStreamOperation, - GoLiveStartStreamMessage, + GoLiveText.Session.StartStreamOperation, + GoLiveText.Session.StartStreamMessage, async () => { await GoLiveOutputRuntime.StartStreamAsync(BuildRuntimeRequest(SelectedCamera)); - GoLiveSession.ToggleStream(SceneCameras); + + if (!GoLiveOutputRuntime.State.HasLiveOutputs) + { + Diagnostics.ReportRecoverable( + GoLiveText.Session.StreamPrerequisiteOperation, + GoLiveText.Session.StreamPrerequisiteMessage, + GoLiveText.Session.StreamPrerequisiteDetail); + return; + } + + GoLiveSession.StartStream(SceneCameras); }); }); } @@ -165,12 +167,12 @@ await RunSerializedInteractionAsync(async () => if (GoLiveSession.State.IsRecordingActive) { await Diagnostics.RunAsync( - GoLiveStopRecordingOperation, - GoLiveStopRecordingMessage, - async () => + GoLiveText.Session.StopRecordingOperation, + GoLiveText.Session.StopRecordingMessage, + async () => { await GoLiveOutputRuntime.StopRecordingAsync(); - GoLiveSession.ToggleRecording(SceneCameras); + GoLiveSession.StopRecording(); }); return; } @@ -178,12 +180,12 @@ await Diagnostics.RunAsync( await EnsureSelectedCameraReadyForProgramAsync(); await EnsureRecordingOutputEnabledAsync(); await Diagnostics.RunAsync( - GoLiveStartRecordingOperation, - GoLiveStartRecordingMessage, - async () => + GoLiveText.Session.StartRecordingOperation, + GoLiveText.Session.StartRecordingMessage, + async () => { await GoLiveOutputRuntime.StartRecordingAsync(BuildRuntimeRequest(SelectedCamera)); - GoLiveSession.ToggleRecording(SceneCameras); + GoLiveSession.StartRecording(SceneCameras); }); }); } @@ -197,10 +199,10 @@ private static string FormatOutputResolution(StreamingResolutionPreset resolutio { return resolution switch { - StreamingResolutionPreset.FullHd1080p60 => "1080p60", - StreamingResolutionPreset.Hd720p30 => "720p30", - StreamingResolutionPreset.UltraHd2160p30 => "2160p30", - _ => "1080p30" + StreamingResolutionPreset.FullHd1080p60 => GoLiveText.Surface.StreamFormatFullHd60, + StreamingResolutionPreset.Hd720p30 => GoLiveText.Surface.StreamFormatHd30, + StreamingResolutionPreset.UltraHd2160p30 => GoLiveText.Surface.StreamFormatUltraHd30, + _ => GoLiveText.Surface.StreamFormatFullHd30 }; } @@ -208,24 +210,24 @@ private static string ResolveResolutionDimensions(StreamingResolutionPreset reso { return resolution switch { - StreamingResolutionPreset.FullHd1080p60 => "1920 × 1080", - StreamingResolutionPreset.Hd720p30 => "1280 × 720", - StreamingResolutionPreset.UltraHd2160p30 => "3840 × 2160", - _ => "1920 × 1080" + StreamingResolutionPreset.FullHd1080p60 => GoLiveText.Surface.ResolutionDimensionsFullHd, + StreamingResolutionPreset.Hd720p30 => GoLiveText.Surface.ResolutionDimensionsHd, + StreamingResolutionPreset.UltraHd2160p30 => GoLiveText.Surface.ResolutionDimensionsUltraHd, + _ => GoLiveText.Surface.ResolutionDimensionsFullHd }; } private static string BuildStageFrameRateLabel(StreamingResolutionPreset resolution) => resolution switch { - StreamingResolutionPreset.FullHd1080p60 => StageFrameRate60Label, - _ => StageFrameRate30Label + StreamingResolutionPreset.FullHd1080p60 => GoLiveText.Session.StageFrameRate60Label, + _ => GoLiveText.Session.StageFrameRate30Label }; private static string FormatSessionElapsed(DateTimeOffset? startedAt) { if (startedAt is null) { - return DefaultProgramTimerLabel; + return GoLiveText.Session.DefaultProgramTimerLabel; } var elapsed = DateTimeOffset.UtcNow - startedAt.Value; diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs index 78aeefb..64ba168 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs @@ -2,67 +2,19 @@ using PrompterOne.Core.Models.Media; using PrompterOne.Core.Models.Workspace; using PrompterOne.Shared.Components.GoLive; +using PrompterOne.Shared.GoLive.Models; namespace PrompterOne.Shared.Pages; public partial class GoLivePage { - private const string ActiveDestinationsMetricLabel = "Destinations"; - private const string ActiveWorkLabel = "Primary channel"; - private const string AudioIdleDetailLabel = "Idle"; - private const string AudioMicChannelId = "mic"; - private const string AudioProgramChannelId = "program"; - private const string AudioProgramChannelLabel = "Program"; - private const string AudioRecordingChannelId = "recording"; - private const string AudioRecordingChannelLabel = "Recording"; - private const string CustomScenePrefix = "custom-scene-"; - private const string CustomSceneTitlePrefix = "Scene "; - private const string DetailLocalProgramLabel = "Local program"; - private const string DestinationToneLiveKit = "livekit"; - private const string DestinationToneLocal = "local"; - private const string DestinationToneRecording = "recording"; - private const string DestinationToneYoutube = "youtube"; - private const string GuestRoomLabel = "Guest room"; - private const string HostParticipantId = "host"; - private const string HostParticipantInitial = "H"; - private const string HostParticipantName = "Host"; - private const string InterviewSceneFallback = "Interview"; - private const string LocalRoomPrefix = "local-"; - private const string MainSceneFallback = "Camera 1"; - private const string MicrophoneMetricLabel = "Mic"; - private const string PictureInPictureSceneId = "scene-picture-in-picture"; - private const string PictureInPictureSceneLabel = "PiP Slides"; - private const string PrimarySceneId = "scene-primary"; - private const string ProgramMetricLabel = "Camera"; - private const string ProgramStandbyDetailLabel = "Program idle"; - private const string RecordingActiveMetricValue = "Saving"; - private const string RecordingMetricLabel = "Recording"; - private const string RecordingReadyDetailLabel = "Ready"; - private const string RecordingReadyMetricValue = "Armed"; - private const string RelayPlatformLabel = "Relay preset"; - private const string RoomCodeFallback = "local-studio"; - private const string RuntimeEngineIdleValue = "Idle"; - private const string RuntimeEngineLabel = "Runtime"; - private const string RuntimeEngineLiveKitValue = "LiveKit"; - private const string RuntimeEngineObsBrowserValue = "OBS browser"; - private const string RuntimeEngineObsLiveKitValue = "OBS + LiveKit"; - private const string RuntimeEngineRecorderValue = "Recorder"; - private const string SceneSlidesId = "scene-slides"; - private const string SceneSlidesLabel = "Slides"; - private const string SecondarySceneId = "scene-secondary"; - private const string SettingsPlatformLabel = "Settings preset"; - private const string StatusBitrateLabel = "Bitrate"; - private const string StatusOutputLabel = "Output"; - private const string StudioSourcesTitle = "Sources"; - private const string SessionMetricLabel = "Session"; - private const string DirectorSourcesTitle = "Cameras"; private const string GoLiveContentBaseClass = "gl-content"; private const string GoLiveFullProgramClass = "gl-layout-fullpgm"; private const string GoLiveHideLeftClass = "gl-hide-left"; private const string GoLiveHideRightClass = "gl-hide-right"; - private string _activeSceneId = PrimarySceneId; + private string _activeSceneId = GoLiveText.Surface.PrimarySceneId; private GoLiveSceneLayout _activeSceneLayout = GoLiveSceneLayout.Full; private GoLiveStudioMode _activeStudioMode = GoLiveStudioMode.Director; private GoLiveStudioTab _activeStudioTab = GoLiveStudioTab.Stream; @@ -84,8 +36,7 @@ public partial class GoLivePage private bool IsRoomActive => _roomCreated || GoLiveOutputRuntime.State.LiveKitActive - || !string.IsNullOrWhiteSpace(_studioSettings.Streaming.LiveKitRoomName) - || _studioSettings.Streaming.VdoNinjaEnabled; + || ResolvePrimaryRoomDestination() is not null; private IReadOnlyList Participants => BuildParticipants(); @@ -106,8 +57,8 @@ public partial class GoLivePage private string SourcesHeaderTitle => _activeStudioMode == GoLiveStudioMode.Director - ? DirectorSourcesTitle - : StudioSourcesTitle; + ? GoLiveText.Surface.DirectorSourcesTitle + : GoLiveText.Surface.SourcesTitle; private string GoLiveContentClass { @@ -163,23 +114,23 @@ private IReadOnlyList BuildAudioChannels() return [ new( - AudioMicChannelId, + GoLiveText.Surface.AudioMicChannelId, PrimaryMicrophoneLabel, - HasPrimaryMicrophone ? PrimaryMicrophoneRoute : NoMicrophoneLabel, + HasPrimaryMicrophone ? PrimaryMicrophoneRoute : GoLiveText.Audio.NoMicrophoneLabel, microphoneLevel), new( - AudioProgramChannelId, - AudioProgramChannelLabel, - GoLiveSession.State.HasActiveSession ? ActiveSourceLabel : ProgramStandbyDetailLabel, + GoLiveText.Surface.AudioProgramChannelId, + GoLiveText.Surface.AudioProgramChannelLabel, + GoLiveSession.State.HasActiveSession ? ActiveSourceLabel : GoLiveText.Surface.ProgramStandbyDetailLabel, programLevel), new( - AudioRecordingChannelId, - AudioRecordingChannelLabel, + GoLiveText.Surface.AudioRecordingChannelId, + GoLiveText.Surface.AudioRecordingChannelLabel, GoLiveOutputRuntime.State.RecordingActive - ? RecordingActiveMetricValue + ? GoLiveText.Surface.RecordingActiveMetricValue : _studioSettings.Streaming.LocalRecordingEnabled - ? RecordingReadyDetailLabel - : AudioIdleDetailLabel, + ? GoLiveText.Surface.RecordingReadyDetailLabel + : GoLiveText.Surface.AudioIdleDetailLabel, recordingLevel) ]; } @@ -195,10 +146,10 @@ private IReadOnlyList BuildParticipants() return [ new( - HostParticipantId, - HostParticipantInitial, - HostParticipantName, - DetailLocalProgramLabel, + GoLiveText.Surface.HostParticipantId, + GoLiveText.Surface.HostParticipantInitial, + GoLiveText.Surface.HostParticipantName, + GoLiveText.Surface.DetailLocalProgramLabel, participantLevel, true) ]; @@ -206,27 +157,28 @@ private IReadOnlyList BuildParticipants() private string BuildRoomCode() { - if (!string.IsNullOrWhiteSpace(_studioSettings.Streaming.LiveKitRoomName)) + var roomDestination = ResolvePrimaryRoomDestination(); + if (!string.IsNullOrWhiteSpace(roomDestination?.RoomName)) { - return _studioSettings.Streaming.LiveKitRoomName; + return roomDestination.RoomName; } if (!string.IsNullOrWhiteSpace(SessionService.State.ScriptId)) { - return string.Concat(LocalRoomPrefix, SessionService.State.ScriptId); + return string.Concat(GoLiveText.Surface.LocalRoomPrefix, SessionService.State.ScriptId); } - return RoomCodeFallback; + return GoLiveText.Surface.RoomCodeFallback; } private IReadOnlyList BuildRuntimeMetrics() { return [ - new(string.IsNullOrWhiteSpace(ActiveSourceLabel) ? CameraFallbackLabel : ActiveSourceLabel, ProgramMetricLabel), - new(PrimaryMicrophoneLabel, MicrophoneMetricLabel), - new(BuildRecordingMetricValue(), RecordingMetricLabel), - new(BuildRuntimeEngineValue(), RuntimeEngineLabel) + new(string.IsNullOrWhiteSpace(ActiveSourceLabel) ? GoLiveText.Session.CameraFallbackLabel : ActiveSourceLabel, GoLiveText.Surface.ProgramMetricLabel), + new(PrimaryMicrophoneLabel, GoLiveText.Surface.MicrophoneMetricLabel), + new(BuildRecordingMetricValue(), GoLiveText.Surface.RecordingMetricLabel), + new(BuildRuntimeEngineValue(), GoLiveText.Surface.RuntimeEngineLabel) ]; } @@ -236,15 +188,19 @@ private IReadOnlyList BuildSceneChips() var secondaryCamera = SceneCameras.Count > 1 ? SceneCameras[1] : null; var scenes = new List { - new(PrimarySceneId, primaryCamera?.Label ?? MainSceneFallback, GoLiveSceneChipKind.Camera, primaryCamera?.SourceId), - new(SecondarySceneId, secondaryCamera?.Label ?? InterviewSceneFallback, GoLiveSceneChipKind.Split, secondaryCamera?.SourceId), - new(SceneSlidesId, SceneSlidesLabel, GoLiveSceneChipKind.Slides, null), - new(PictureInPictureSceneId, PictureInPictureSceneLabel, GoLiveSceneChipKind.PictureInPicture, primaryCamera?.SourceId) + new(GoLiveText.Surface.PrimarySceneId, primaryCamera?.Label ?? GoLiveText.Surface.MainSceneFallback, GoLiveSceneChipKind.Camera, primaryCamera?.SourceId), + new(GoLiveText.Surface.SecondarySceneId, secondaryCamera?.Label ?? GoLiveText.Surface.InterviewSceneFallback, GoLiveSceneChipKind.Split, secondaryCamera?.SourceId), + new(GoLiveText.Surface.SceneSlidesId, GoLiveText.Surface.SceneSlidesLabel, GoLiveSceneChipKind.Slides, null), + new(GoLiveText.Surface.PictureInPictureSceneId, GoLiveText.Surface.PictureInPictureSceneLabel, GoLiveSceneChipKind.PictureInPicture, primaryCamera?.SourceId) }; for (var index = 1; index <= _customSceneCount; index++) { - scenes.Add(new($"{CustomScenePrefix}{index}", $"{CustomSceneTitlePrefix}{index + 4}", GoLiveSceneChipKind.Custom, null)); + scenes.Add(new( + $"{GoLiveText.Surface.CustomScenePrefix}{index}", + $"{GoLiveText.Surface.CustomSceneTitlePrefix}{index + 4}", + GoLiveSceneChipKind.Custom, + null)); } return scenes; @@ -255,10 +211,10 @@ private IReadOnlyList BuildStatusMetrics() var enabledDestinations = DestinationSummary.Count(destination => destination.IsEnabled); return [ - new(BitrateTelemetry, StatusBitrateLabel), - new(FormatOutputResolution(_studioSettings.Streaming.OutputResolution), StatusOutputLabel), - new(enabledDestinations.ToString(CultureInfo.InvariantCulture), ActiveDestinationsMetricLabel), - new(ActiveSessionLabel, SessionMetricLabel) + new(BitrateTelemetry, GoLiveText.Surface.StatusBitrateLabel), + new(FormatOutputResolution(_studioSettings.Streaming.OutputResolution), GoLiveText.Surface.StatusOutputLabel), + new(enabledDestinations.ToString(CultureInfo.InvariantCulture), GoLiveText.Surface.ActiveDestinationsMetricLabel), + new(ActiveSessionLabel, GoLiveText.Surface.SessionMetricLabel) ]; } @@ -266,127 +222,26 @@ private string BuildRecordingMetricValue() { if (GoLiveOutputRuntime.State.RecordingActive) { - return RecordingActiveMetricValue; + return GoLiveText.Surface.RecordingActiveMetricValue; } return _studioSettings.Streaming.LocalRecordingEnabled - ? RecordingReadyMetricValue - : AudioIdleDetailLabel; + ? GoLiveText.Surface.RecordingReadyMetricValue + : GoLiveText.Surface.AudioIdleDetailLabel; } private string BuildRuntimeEngineValue() { return (GoLiveOutputRuntime.State.ObsActive, GoLiveOutputRuntime.State.LiveKitActive, GoLiveOutputRuntime.State.RecordingActive) switch { - (true, true, _) => RuntimeEngineObsLiveKitValue, - (true, false, _) => RuntimeEngineObsBrowserValue, - (false, true, _) => RuntimeEngineLiveKitValue, - (false, false, true) => RuntimeEngineRecorderValue, - _ => RuntimeEngineIdleValue + (true, true, _) => GoLiveText.Surface.RuntimeEngineObsLiveKitValue, + (true, false, _) => GoLiveText.Surface.RuntimeEngineObsBrowserValue, + (false, true, _) => GoLiveText.Surface.RuntimeEngineLiveKitValue, + (false, false, true) => GoLiveText.Surface.RuntimeEngineRecorderValue, + _ => GoLiveText.Surface.RuntimeEngineIdleValue }; } - private IReadOnlyList BuildDestinationSummary() - { - return - [ - BuildDestinationSummary( - GoLiveTargetCatalog.TargetIds.Obs, - GoLiveTargetCatalog.TargetNames.Obs, - SettingsPlatformLabel, - _studioSettings.Streaming.ObsVirtualCameraEnabled, - DestinationToneLocal), - BuildDestinationSummary( - GoLiveTargetCatalog.TargetIds.Recording, - GoLiveTargetCatalog.TargetNames.Recording, - ActiveWorkLabel, - _studioSettings.Streaming.LocalRecordingEnabled, - DestinationToneRecording), - BuildRemoteDestinationSummary( - GoLiveTargetCatalog.TargetIds.LiveKit, - GoLiveTargetCatalog.TargetNames.LiveKit, - GuestRoomLabel, - _studioSettings.Streaming.LiveKitEnabled, - DestinationToneLiveKit, - _studioSettings.Streaming.LiveKitServerUrl, - _studioSettings.Streaming.LiveKitRoomName, - _studioSettings.Streaming.LiveKitToken), - BuildRemoteDestinationSummary( - GoLiveTargetCatalog.TargetIds.Youtube, - GoLiveTargetCatalog.TargetNames.Youtube, - RelayPlatformLabel, - _studioSettings.Streaming.YoutubeEnabled, - DestinationToneYoutube, - _studioSettings.Streaming.YoutubeRtmpUrl, - _studioSettings.Streaming.YoutubeStreamKey) - ]; - } - - private GoLiveDestinationSummaryViewModel BuildDestinationSummary( - string targetId, - string name, - string platformLabel, - bool isEnabled, - string tone) - { - var isReady = BuildDestinationIsReady(isEnabled, targetId); - return new GoLiveDestinationSummaryViewModel( - targetId, - name, - platformLabel, - isEnabled, - isReady, - BuildLocalSummary(targetId), - BuildTargetStatusLabel(isEnabled, targetId), - tone); - } - - private GoLiveDestinationSummaryViewModel BuildRemoteDestinationSummary( - string targetId, - string name, - string platformLabel, - bool isEnabled, - string tone, - params string[] requiredValues) - { - var isReady = BuildDestinationIsReady(isEnabled, targetId, requiredValues); - return new GoLiveDestinationSummaryViewModel( - targetId, - name, - platformLabel, - isEnabled, - isReady, - BuildRemoteSummary(isEnabled, targetId, requiredValues), - BuildTargetStatusLabel(isEnabled, targetId, requiredValues), - tone); - } - - private async Task ToggleDestinationSummaryAsync(string targetId) - { - if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Youtube, StringComparison.Ordinal)) - { - await ToggleYoutubeSettingsAsync(); - return; - } - - if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Obs, StringComparison.Ordinal)) - { - await ToggleObsOutputAsync(); - return; - } - - if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.LiveKit, StringComparison.Ordinal)) - { - await ToggleLiveKitSettingsAsync(); - return; - } - - if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Recording, StringComparison.Ordinal)) - { - await ToggleRecordingOutputAsync(); - } - } - private Task SelectStudioModeAsync(GoLiveStudioMode mode) { _activeStudioMode = mode; @@ -432,7 +287,7 @@ private Task SelectSceneAsync(string sceneId) private Task AddSceneAsync() { _customSceneCount++; - _activeSceneId = $"{CustomScenePrefix}{_customSceneCount}"; + _activeSceneId = $"{GoLiveText.Surface.CustomScenePrefix}{_customSceneCount}"; return Task.CompletedTask; } diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor index aaa85da..e932dda 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor @@ -2,6 +2,7 @@ @namespace PrompterOne.Shared.Pages @using PrompterOne.Core.Models.Workspace @using PrompterOne.Shared.Components.GoLive +@using PrompterOne.Shared.GoLive.Models
- @RecordingIndicatorLabel + @GoLiveText.Session.RecordingIndicatorLabel + + + +@code { + private const string SelectedStatusClass = "set-dest-ok"; + + [Parameter, EditorRequired] public StreamStudioSettings Settings { get; set; } = default!; + [Parameter, EditorRequired] public StreamingProfile Destination { get; set; } = default!; + [Parameter] public IReadOnlyList Sources { get; set; } = []; + [Parameter] public Func IsCardOpen { get; set; } = static _ => false; + [Parameter] public EventCallback RemoveDestination { get; set; } + [Parameter] public EventCallback ToggleCard { get; set; } + [Parameter] public EventCallback ToggleDestination { get; set; } + [Parameter] public EventCallback<(string TargetId, string SourceId)> ToggleDestinationSource { get; set; } + [Parameter] public EventCallback<(string DestinationId, string FieldId, string Value)> UpdateField { get; set; } + + private string CardId => SettingsStreamingCardIds.ExternalDestination(Destination.Id); + + private StreamingPublishDescriptor Descriptor => DescriptorResolver.Describe(Destination); + + private bool IsReady => Destination.IsEnabled + && SelectedSourceIds.Count > 0 + && Descriptor.IsReady; + + private StreamingPlatformPresentation Presentation => StreamingPlatformPresentationCatalog.Get(Destination.PlatformKind); + + private IReadOnlyList SelectedSourceIds => + GoLiveDestinationRouting.GetSelectedSourceIds( + Settings, + Destination.Id, + Sources); + + private string StatusClass => IsReady ? SelectedStatusClass : string.Empty; + + private string StatusLabel + { + get + { + if (!Destination.IsEnabled) + { + return SettingsStreamingText.DestinationDisabledStatusLabel; + } + + if (SelectedSourceIds.Count == 0 || !Descriptor.IsReady) + { + return SettingsStreamingText.DestinationNeedsSetupStatusLabel; + } + + return Descriptor.RequiresExternalRelay + ? SettingsStreamingText.DestinationRelayStatusLabel + : SettingsStreamingText.DestinationReadyStatusLabel; + } + } + + private string Summary + { + get + { + if (!Destination.IsEnabled) + { + return SettingsStreamingText.DestinationDisabledSummary; + } + + if (SelectedSourceIds.Count == 0) + { + return SettingsStreamingText.DestinationNoSourceSummary; + } + + return Descriptor.Summary; + } + } + + private static string GetValue(ChangeEventArgs args) => args.Value?.ToString() ?? string.Empty; +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor index 71db4b0..e406e1c 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor @@ -1,28 +1,28 @@ @namespace PrompterOne.Shared.Components.Settings -@using PrompterOne.Core.Models.Media +@using PrompterOne.Core.Models.Streaming @using PrompterOne.Core.Models.Workspace -@using PrompterOne.Core.Services.Streaming @using PrompterOne.Shared.Settings.Components +@using PrompterOne.Shared.Settings.Models
-

Output Defaults

+

@SettingsStreamingText.OutputDefaultsTitle

- +
- +
- +
- +
@@ -38,7 +38,7 @@
- +
@@ -49,425 +49,66 @@
-

Destinations

+

@SettingsStreamingText.DestinationsTitle

- - - -
- -
- -
- - - @BuildLocalSummary(GoLiveTargetCatalog.TargetIds.Obs) - -
-
- - -
- - - @BuildLocalSummary(GoLiveTargetCatalog.TargetIds.Ndi) - -
-
- - -
- - - @BuildLocalSummary(GoLiveTargetCatalog.TargetIds.Recording) - -
-
- - - - - - - - -
- - -
-
- - -
-
- - -
-
- - - - - - - -
- - -
-
- - -
-
- - - - - - - -
- - -
-
- - -
-
- - - - - - - - -
- - -
-
- - -
-
- - - - - - - -
- - -
-
- - -
-
- - -
-
-
- -@code { - private static readonly IReadOnlyList OutputModeOptions = - [ - new(VirtualCameraOutputModeValue, "Virtual Camera"), - new(NdiOutputModeValue, "NDI Output"), - new(DirectRtmpOutputModeValue, "Direct RTMP"), - new(LocalRecordingOutputModeValue, "Local Recording"), - ]; - - private static readonly IReadOnlyList OutputResolutionOptions = - [ - new(nameof(StreamingResolutionPreset.FullHd1080p30), "1920 x 1080 @ 30fps"), - new(nameof(StreamingResolutionPreset.FullHd1080p60), "1920 x 1080 @ 60fps"), - new(nameof(StreamingResolutionPreset.Hd720p30), "1280 x 720 @ 30fps"), - new(nameof(StreamingResolutionPreset.UltraHd2160p30), "3840 x 2160 @ 30fps"), - ]; - - private const string CustomRtmpCardId = "streaming-custom-rtmp"; - private const string DirectRtmpOutputModeValue = "direct-rtmp"; - private const string DisabledSummary = "Disabled until you arm this destination."; - private const string DisabledStatusLabel = "Disabled"; - private const string EnabledStatusLabel = "Ready"; - private const string LiveKitCardId = "streaming-livekit"; - private const string LocalRecordingOutputModeValue = "local-recording"; - private const string NdiCardId = "streaming-ndi"; - private const string NdiOutputModeValue = "ndi-output"; - private const string NeedsSetupStatusLabel = "Needs setup"; - private const string NoSourceSummary = "Select at least one scene camera for this destination."; - private const string ObsCardId = "streaming-obs"; - private const string OnCssClass = "on"; - private const string RecordingCardId = "streaming-recording"; - private const string SelectedStatusClass = "set-dest-ok"; - private const string TwitchCardId = "streaming-twitch"; - private const string VdoCardId = "streaming-vdo"; - private const string VirtualCameraOutputModeValue = "virtual-camera"; - private const string YoutubeCardId = "streaming-youtube"; - - private bool _isAddDestinationMenuOpen; - - [Parameter, EditorRequired] public StreamStudioSettings Settings { get; set; } = default!; - [Parameter] public IReadOnlyList Sources { get; set; } = []; - [Parameter] public string SelectedOutputModeValue { get; set; } = VirtualCameraOutputModeValue; - [Parameter] public Func IsCardOpen { get; set; } = static _ => false; - [Parameter] public EventCallback BitrateChanged { get; set; } - [Parameter] public EventCallback CustomRtmpKeyChanged { get; set; } - [Parameter] public EventCallback CustomRtmpNameChanged { get; set; } - [Parameter] public EventCallback CustomRtmpUrlChanged { get; set; } - [Parameter] public EventCallback LiveKitRoomChanged { get; set; } - [Parameter] public EventCallback LiveKitServerChanged { get; set; } - [Parameter] public EventCallback LiveKitTokenChanged { get; set; } - [Parameter] public EventCallback OutputModeChanged { get; set; } - [Parameter] public EventCallback OutputResolutionChanged { get; set; } - [Parameter] public EventCallback ToggleCard { get; set; } - [Parameter] public EventCallback ToggleCustomRtmp { get; set; } - [Parameter] public EventCallback ToggleIncludeCamera { get; set; } - [Parameter] public EventCallback ToggleLiveKit { get; set; } - [Parameter] public EventCallback ToggleNdi { get; set; } - [Parameter] public EventCallback ToggleObs { get; set; } - [Parameter] public EventCallback ToggleRecording { get; set; } - [Parameter] public EventCallback<(string TargetId, string SourceId)> ToggleDestinationSource { get; set; } - [Parameter] public EventCallback ToggleTextOverlay { get; set; } - [Parameter] public EventCallback ToggleTwitch { get; set; } - [Parameter] public EventCallback ToggleVdo { get; set; } - [Parameter] public EventCallback ToggleYoutube { get; set; } - [Parameter] public EventCallback TwitchKeyChanged { get; set; } - [Parameter] public EventCallback TwitchUrlChanged { get; set; } - [Parameter] public EventCallback VdoPublishUrlChanged { get; set; } - [Parameter] public EventCallback VdoRoomChanged { get; set; } - [Parameter] public EventCallback YoutubeKeyChanged { get; set; } - [Parameter] public EventCallback YoutubeUrlChanged { get; set; } - - private string AddDestinationMenuCssClass => - _isAddDestinationMenuOpen ? "set-add-source-menu open" : "set-add-source-menu"; - - private IReadOnlyList GetSelectedSourceIds(string targetId) => - GoLiveDestinationRouting.GetSelectedSourceIds(Settings, targetId, Sources); - - private bool BuildIsReady(bool isEnabled, string targetId, params string[] requiredValues) + @foreach (var platform in StreamingPlatformCatalog.All) { - if (!isEnabled) - { - return false; - } - - if (GetSelectedSourceIds(targetId).Count == 0) - { - return false; - } - - return requiredValues.All(value => !string.IsNullOrWhiteSpace(value)); + } +
- private string BuildLocalSummary(string targetId) - { - var selectedSources = GetSelectedSourceIds(targetId); - return selectedSources.Count == 0 - ? NoSourceSummary - : $"{selectedSources.Count} camera source(s) armed from Settings."; - } - - private string BuildRemoteSummary(bool isEnabled, string targetId, params string[] requiredValues) - { - if (!isEnabled) - { - return DisabledSummary; - } - - if (GetSelectedSourceIds(targetId).Count == 0) - { - return NoSourceSummary; - } - - return requiredValues.All(value => !string.IsNullOrWhiteSpace(value)) - ? "Credentials and source routing are stored in browser settings." - : "Complete the destination fields and source routing before going live."; - } - - private string BuildStatusClass(bool isEnabled, string targetId, params string[] requiredValues) => - BuildIsReady(isEnabled, targetId, requiredValues) ? SelectedStatusClass : string.Empty; - - private string BuildTargetStatusLabel(bool isEnabled, string targetId, params string[] requiredValues) - { - if (!isEnabled) - { - return DisabledStatusLabel; - } - - return BuildIsReady(isEnabled, targetId, requiredValues) - ? EnabledStatusLabel - : NeedsSetupStatusLabel; - } - - private async Task OpenDestinationCardAsync(string cardId) - { - _isAddDestinationMenuOpen = false; - - if (!IsCardOpen(cardId)) +
+ @foreach (var localTarget in LocalTargetCards) { - await ToggleCard.InvokeAsync(cardId); - } + +
+ + + @BuildLocalSummary(localTarget.TargetId) + +
+
} - private void ToggleDestinationMenu() => _isAddDestinationMenuOpen = !_isAddDestinationMenuOpen; - - private async Task ToggleStreamingCardAsync(string cardId) + @foreach (var destination in ExternalDestinations) { - _isAddDestinationMenuOpen = false; - await ToggleCard.InvokeAsync(cardId); + } -} +
diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor.cs b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor.cs new file mode 100644 index 0000000..c02a0e8 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Components; +using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Streaming; +using PrompterOne.Core.Models.Workspace; +using PrompterOne.Core.Services.Streaming; +using PrompterOne.Shared.Settings.Components; +using PrompterOne.Shared.Settings.Models; + +namespace PrompterOne.Shared.Components.Settings; + +public partial class SettingsStreamingPanel +{ + private static readonly IReadOnlyList OutputModeOptions = + [ + new(VirtualCameraOutputModeValue, SettingsStreamingText.VirtualCameraOutputModeLabel), + new(NdiOutputModeValue, SettingsStreamingText.NdiOutputModeLabel), + new(DirectRtmpOutputModeValue, SettingsStreamingText.DirectRtmpOutputModeLabel), + new(LocalRecordingOutputModeValue, SettingsStreamingText.LocalRecordingOutputModeLabel), + ]; + + private static readonly IReadOnlyList OutputResolutionOptions = + [ + new(nameof(StreamingResolutionPreset.FullHd1080p30), SettingsStreamingText.FullHd1080p30Label), + new(nameof(StreamingResolutionPreset.FullHd1080p60), SettingsStreamingText.FullHd1080p60Label), + new(nameof(StreamingResolutionPreset.Hd720p30), SettingsStreamingText.Hd720p30Label), + new(nameof(StreamingResolutionPreset.UltraHd2160p30), SettingsStreamingText.UltraHd2160p30Label), + ]; + + private const string DirectRtmpOutputModeValue = "direct-rtmp"; + private const string LocalRecordingOutputModeValue = "local-recording"; + private const string NdiOutputModeValue = "ndi-output"; + private const string OnCssClass = "on"; + private const string SelectedStatusClass = "set-dest-ok"; + private const string VirtualCameraOutputModeValue = "virtual-camera"; + + private bool _isAddDestinationMenuOpen; + + [Parameter, EditorRequired] public StreamStudioSettings Settings { get; set; } = default!; + [Parameter] public IReadOnlyList Sources { get; set; } = []; + [Parameter] public string SelectedOutputModeValue { get; set; } = VirtualCameraOutputModeValue; + [Parameter] public Func IsCardOpen { get; set; } = static _ => false; + [Parameter] public EventCallback AddExternalDestination { get; set; } + [Parameter] public EventCallback BitrateChanged { get; set; } + [Parameter] public EventCallback OutputModeChanged { get; set; } + [Parameter] public EventCallback OutputResolutionChanged { get; set; } + [Parameter] public EventCallback RemoveExternalDestination { get; set; } + [Parameter] public EventCallback ToggleCard { get; set; } + [Parameter] public EventCallback ToggleExternalDestination { get; set; } + [Parameter] public EventCallback ToggleIncludeCamera { get; set; } + [Parameter] public EventCallback ToggleNdi { get; set; } + [Parameter] public EventCallback ToggleObs { get; set; } + [Parameter] public EventCallback ToggleRecording { get; set; } + [Parameter] public EventCallback<(string TargetId, string SourceId)> ToggleDestinationSource { get; set; } + [Parameter] public EventCallback ToggleTextOverlay { get; set; } + [Parameter] public EventCallback<(string DestinationId, string FieldId, string Value)> UpdateExternalDestinationField { get; set; } + + private string AddDestinationMenuCssClass => + _isAddDestinationMenuOpen ? "set-add-source-menu open" : "set-add-source-menu"; + + private IReadOnlyList ExternalDestinations => Settings.ExternalDestinations ?? Array.Empty(); + + private static IReadOnlyList LocalTargetCards => SettingsStreamingLocalTargetCatalog.All; + + private async Task AddDestinationAndOpenCardAsync(StreamingPlatformKind kind) + { + var existingIds = ExternalDestinations.Select(destination => destination.Id); + var nextCardId = SettingsStreamingCardIds.ExternalDestination( + StreamingPlatformCatalog.CreateProfile(kind, existingIds).Id); + + _isAddDestinationMenuOpen = false; + await AddExternalDestination.InvokeAsync(kind); + + if (!IsCardOpen(nextCardId)) + { + await ToggleCard.InvokeAsync(nextCardId); + } + } + + private IReadOnlyList GetSelectedSourceIds(string targetId) => + GoLiveDestinationRouting.GetSelectedSourceIds(Settings, targetId, Sources); + + private bool BuildLocalTargetIsReady(string targetId) + { + if (!IsLocalTargetEnabled(targetId)) + { + return false; + } + + return GetSelectedSourceIds(targetId).Count > 0; + } + + private string BuildLocalSummary(string targetId) + { + var selectedSources = GetSelectedSourceIds(targetId); + return selectedSources.Count == 0 + ? SettingsStreamingText.DestinationNoSourceSummary + : $"{selectedSources.Count}{SettingsStreamingText.LocalDestinationSummarySuffix}"; + } + + private string BuildLocalTargetStatusClass(string targetId) => + BuildLocalTargetIsReady(targetId) ? SelectedStatusClass : string.Empty; + + private string BuildLocalTargetStatusLabel(string targetId) + { + if (!IsLocalTargetEnabled(targetId)) + { + return SettingsStreamingText.DestinationDisabledStatusLabel; + } + + return BuildLocalTargetIsReady(targetId) + ? SettingsStreamingText.DestinationReadyStatusLabel + : SettingsStreamingText.DestinationNeedsSetupStatusLabel; + } + + private bool IsLocalTargetEnabled(string targetId) => targetId switch + { + GoLiveTargetCatalog.TargetIds.Obs => Settings.ObsVirtualCameraEnabled, + GoLiveTargetCatalog.TargetIds.Ndi => Settings.NdiOutputEnabled, + GoLiveTargetCatalog.TargetIds.Recording => Settings.LocalRecordingEnabled, + _ => false + }; + + private async Task ToggleLocalTargetAsync(string targetId) + { + _isAddDestinationMenuOpen = false; + + if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Obs, StringComparison.Ordinal)) + { + await ToggleObs.InvokeAsync(); + return; + } + + if (string.Equals(targetId, GoLiveTargetCatalog.TargetIds.Ndi, StringComparison.Ordinal)) + { + await ToggleNdi.InvokeAsync(); + return; + } + + await ToggleRecording.InvokeAsync(); + } + + private void ToggleDestinationMenu() => _isAddDestinationMenuOpen = !_isAddDestinationMenuOpen; + + private async Task ToggleStreamingCardAsync(string cardId) + { + _isAddDestinationMenuOpen = false; + await ToggleCard.InvokeAsync(cardId); + } +} diff --git a/src/PrompterOne.Shared/Settings/Models/SettingsStreamingCardIds.cs b/src/PrompterOne.Shared/Settings/Models/SettingsStreamingCardIds.cs new file mode 100644 index 0000000..7b6874c --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Models/SettingsStreamingCardIds.cs @@ -0,0 +1,15 @@ +namespace PrompterOne.Shared.Settings.Models; + +public static class SettingsStreamingCardIds +{ + public const string CustomRtmp = "streaming-custom-rtmp"; + public const string LiveKit = "streaming-livekit"; + public const string Ndi = "streaming-ndi"; + public const string Obs = "streaming-obs"; + public const string Recording = "streaming-recording"; + public const string Twitch = "streaming-twitch"; + public const string VdoNinja = "streaming-vdo"; + public const string Youtube = "streaming-youtube"; + + public static string ExternalDestination(string destinationId) => $"streaming-destination-{destinationId}"; +} diff --git a/src/PrompterOne.Shared/Settings/Models/SettingsStreamingLocalTargetCatalog.cs b/src/PrompterOne.Shared/Settings/Models/SettingsStreamingLocalTargetCatalog.cs new file mode 100644 index 0000000..c40276b --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Models/SettingsStreamingLocalTargetCatalog.cs @@ -0,0 +1,40 @@ +using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.Contracts; + +namespace PrompterOne.Shared.Settings.Models; + +public sealed record SettingsStreamingLocalTargetDefinition( + string TargetId, + string CardId, + string Name, + string AccountLabel, + string Description, + string ToggleTestId); + +public static class SettingsStreamingLocalTargetCatalog +{ + public static IReadOnlyList All { get; } = + [ + new( + GoLiveTargetCatalog.TargetIds.Obs, + SettingsStreamingCardIds.Obs, + GoLiveTargetCatalog.TargetNames.Obs, + "OBS Virtual Camera · Local browser output", + "Keep the browser program available to OBS Virtual Camera without touching the live runtime screen.", + UiTestIds.Settings.StreamingObsToggle), + new( + GoLiveTargetCatalog.TargetIds.Ndi, + SettingsStreamingCardIds.Ndi, + GoLiveTargetCatalog.TargetNames.Ndi, + "NDI Output · Local network", + "Publish the browser program as an NDI output target configured in local browser storage.", + UiTestIds.Settings.StreamingNdiToggle), + new( + GoLiveTargetCatalog.TargetIds.Recording, + SettingsStreamingCardIds.Recording, + GoLiveTargetCatalog.TargetNames.Recording, + "Local Recording · Browser workspace", + "Arm local recording before going live, then control recording from the dedicated live runtime.", + UiTestIds.Settings.StreamingRecordingToggle) + ]; +} diff --git a/src/PrompterOne.Shared/Settings/Models/SettingsStreamingText.cs b/src/PrompterOne.Shared/Settings/Models/SettingsStreamingText.cs new file mode 100644 index 0000000..3a9fcec --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Models/SettingsStreamingText.cs @@ -0,0 +1,37 @@ +namespace PrompterOne.Shared.Settings.Models; + +public static class SettingsStreamingText +{ + public const string AddDestinationButtonLabel = "Add Destination"; + public const string AvailableInProgramRoutingLabel = "Available in program routing"; + public const string BitrateLabel = "Bitrate (kbps)"; + public const string DestinationDisabledSummary = "Disabled until you arm this destination."; + public const string DestinationDisabledStatusLabel = "Disabled"; + public const string DestinationNeedsSetupStatusLabel = "Needs setup"; + public const string DestinationNoSourceSummary = "Select at least one scene camera for this destination."; + public const string DestinationReadyStatusLabel = "Ready"; + public const string DestinationRelayStatusLabel = "Relay"; + public const string DestinationsTitle = "Destinations"; + public const string FullHd1080p30Label = "1920 x 1080 @ 30fps"; + public const string FullHd1080p60Label = "1920 x 1080 @ 60fps"; + public const string IncludeCameraInOutputLabel = "Include Camera in Output"; + public const string LocalDestinationSummarySuffix = " camera source(s) armed from Settings."; + public const string LocalRecordingOutputModeLabel = "Local Recording"; + public const string NdiOutputModeLabel = "NDI Output"; + public const string OutputDefaultsTitle = "Output Defaults"; + public const string OutputModeLabel = "Output Mode"; + public const string OutputResolutionLabel = "Output Resolution"; + public const string PublishUrlLabel = "Publish URL"; + public const string RemoveDestinationLabel = "Remove Destination"; + public const string RoomNameLabel = "Room Name"; + public const string RtmpUrlLabel = "RTMP / RTMPS URL"; + public const string ServerUrlLabel = "Server URL"; + public const string ShowTextOverlayLabel = "Show Text Overlay"; + public const string StreamKeyLabel = "Stream Key"; + public const string TargetNameLabel = "Target Name"; + public const string TokenLabel = "Access Token"; + public const string UltraHd2160p30Label = "3840 x 2160 @ 30fps"; + public const string VirtualCameraOutputModeLabel = "Virtual Camera"; + public const string DirectRtmpOutputModeLabel = "Direct RTMP"; + public const string Hd720p30Label = "1280 x 720 @ 30fps"; +} diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Streaming.cs b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Streaming.cs index 3c2e948..9dde727 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Streaming.cs +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.Streaming.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Components; +using PrompterOne.Core.Models.Streaming; using PrompterOne.Core.Models.Workspace; using PrompterOne.Core.Services.Streaming; @@ -19,135 +20,84 @@ public partial class SettingsPage _ => VirtualCameraOutputModeValue }; - private async Task ToggleObsOutputAsync() - { - _studioSettings = _studioSettings with + private Task ToggleObsOutputAsync() => + UpdateStreamingSettingsAsync(streaming => streaming with { - Streaming = _studioSettings.Streaming with - { - ObsVirtualCameraEnabled = !_studioSettings.Streaming.ObsVirtualCameraEnabled, - OutputMode = StreamingOutputMode.VirtualCamera - } - }; - - await PersistStudioSettingsAsync(); - } + ObsVirtualCameraEnabled = !streaming.ObsVirtualCameraEnabled, + OutputMode = StreamingOutputMode.VirtualCamera + }); - private async Task ToggleNdiOutputAsync() - { - _studioSettings = _studioSettings with + private Task ToggleNdiOutputAsync() => + UpdateStreamingSettingsAsync(streaming => streaming with { - Streaming = _studioSettings.Streaming with - { - NdiOutputEnabled = !_studioSettings.Streaming.NdiOutputEnabled, - OutputMode = StreamingOutputMode.NdiOutput - } - }; + NdiOutputEnabled = !streaming.NdiOutputEnabled, + OutputMode = StreamingOutputMode.NdiOutput + }); - await PersistStudioSettingsAsync(); - } - - private async Task ToggleRecordingOutputAsync() - { - _studioSettings = _studioSettings with + private Task ToggleRecordingOutputAsync() => + UpdateStreamingSettingsAsync(streaming => streaming with { - Streaming = _studioSettings.Streaming with - { - LocalRecordingEnabled = !_studioSettings.Streaming.LocalRecordingEnabled, - OutputMode = StreamingOutputMode.LocalRecording - } - }; - - await PersistStudioSettingsAsync(); - } + LocalRecordingEnabled = !streaming.LocalRecordingEnabled, + OutputMode = StreamingOutputMode.LocalRecording + }); - private async Task ToggleLiveKitSettingsAsync() + private async Task AddExternalDestinationAsync(StreamingPlatformKind kind) { - _studioSettings = _studioSettings with + var existingIds = (_studioSettings.Streaming.ExternalDestinations ?? Array.Empty()) + .Select(destination => destination.Id); + var destination = StreamingPlatformCatalog.CreateProfile(kind, existingIds); + destination = destination with { - Streaming = _studioSettings.Streaming with - { - LiveKitEnabled = !_studioSettings.Streaming.LiveKitEnabled - } + Name = BuildDestinationDisplayName(kind, destination.Id) }; - await PersistStudioSettingsAsync(); - } - - private async Task ToggleVdoSettingsAsync() - { - _studioSettings = _studioSettings with + if (destination.ProviderKind == StreamingProviderKind.Rtmp) { - Streaming = _studioSettings.Streaming with - { - VdoNinjaEnabled = !_studioSettings.Streaming.VdoNinjaEnabled - } - }; - - await PersistStudioSettingsAsync(); - } + destination = destination.SetPrimaryDestination( + destination.Name, + destination.GetPrimaryDestinationUrl(), + destination.GetPrimaryDestinationStreamKey()); + } - private async Task ToggleYoutubeSettingsAsync() - { - _studioSettings = _studioSettings with + await UpdateStreamingSettingsAsync(streaming => streaming with { - Streaming = _studioSettings.Streaming with - { - YoutubeEnabled = !_studioSettings.Streaming.YoutubeEnabled, - OutputMode = StreamingOutputMode.DirectRtmp - } - }; - - await PersistStudioSettingsAsync(); + ExternalDestinations = (streaming.ExternalDestinations ?? Array.Empty()) + .Append(destination) + .ToArray() + }); } - private async Task ToggleTwitchSettingsAsync() - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with - { - TwitchEnabled = !_studioSettings.Streaming.TwitchEnabled, - OutputMode = StreamingOutputMode.DirectRtmp - } - }; + private Task ToggleExternalDestinationAsync(string destinationId) => + UpdateStreamingSettingsAsync( + streaming => UpdateExternalDestination( + streaming, + destinationId, + destination => destination with { IsEnabled = !destination.IsEnabled })); - await PersistStudioSettingsAsync(); - } - - private async Task ToggleCustomRtmpSettingsAsync() - { - var customName = string.IsNullOrWhiteSpace(_studioSettings.Streaming.CustomRtmpName) - ? StreamingDefaults.CustomTargetName - : _studioSettings.Streaming.CustomRtmpName; - - _studioSettings = _studioSettings with + private Task RemoveExternalDestinationAsync(string destinationId) => + UpdateStreamingSettingsAsync(streaming => streaming with { - Streaming = _studioSettings.Streaming with - { - CustomRtmpEnabled = !_studioSettings.Streaming.CustomRtmpEnabled, - CustomRtmpName = customName, - OutputMode = StreamingOutputMode.DirectRtmp, - RtmpUrl = _studioSettings.Streaming.CustomRtmpUrl, - StreamKey = _studioSettings.Streaming.CustomRtmpStreamKey - } - }; + ExternalDestinations = (streaming.ExternalDestinations ?? Array.Empty()) + .Where(destination => !string.Equals(destination.Id, destinationId, StringComparison.Ordinal)) + .ToArray() + }); - await PersistStudioSettingsAsync(); - } + private Task UpdateExternalDestinationFieldAsync((string DestinationId, string FieldId, string Value) update) => + UpdateStreamingSettingsAsync( + streaming => UpdateExternalDestination( + streaming, + update.DestinationId, + destination => ApplyExternalDestinationField(destination, update.FieldId, update.Value))); private async Task ToggleStreamingDestinationSourceAsync((string TargetId, string SourceId) update) { - _studioSettings = _studioSettings with - { - Streaming = GoLiveDestinationRouting.ToggleSource( - _studioSettings.Streaming, + await UpdateStreamingSettingsAsync( + streaming => GoLiveDestinationRouting.ToggleSource( + streaming, update.TargetId, update.SourceId, - _sceneCameras) - }; - - await PersistStudioSettingsAsync(); + _sceneCameras), + normalizeSources: false); } private async Task OnStreamingOutputResolutionChanged(ChangeEventArgs args) @@ -157,12 +107,7 @@ private async Task OnStreamingOutputResolutionChanged(ChangeEventArgs args) return; } - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { OutputResolution = outputResolution } - }; - - await PersistStudioSettingsAsync(); + await UpdateStreamingSettingsAsync(streaming => streaming with { OutputResolution = outputResolution }); } private async Task OnStreamingOutputModeChanged(ChangeEventArgs args) @@ -175,12 +120,7 @@ private async Task OnStreamingOutputModeChanged(ChangeEventArgs args) _ => StreamingOutputMode.VirtualCamera }; - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { OutputMode = nextMode } - }; - - await PersistStudioSettingsAsync(); + await UpdateStreamingSettingsAsync(streaming => streaming with { OutputMode = nextMode }); } private async Task UpdateStreamingBitrateAsync(ChangeEventArgs args) @@ -190,181 +130,104 @@ private async Task UpdateStreamingBitrateAsync(ChangeEventArgs args) return; } - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { BitrateKbps = Math.Max(250, bitrate) } - }; - - await PersistStudioSettingsAsync(); + await UpdateStreamingSettingsAsync(streaming => streaming with { BitrateKbps = Math.Max(250, bitrate) }); } - private async Task ToggleSettingsTextOverlayAsync() - { - _studioSettings = _studioSettings with + private Task ToggleSettingsTextOverlayAsync() => + UpdateStreamingSettingsAsync(streaming => streaming with { - Streaming = _studioSettings.Streaming with - { - ShowTextOverlay = !_studioSettings.Streaming.ShowTextOverlay - } - }; - - await PersistStudioSettingsAsync(); - } + ShowTextOverlay = !streaming.ShowTextOverlay + }); private async Task ToggleSettingsIncludeCameraAsync() { var nextValue = !_studioSettings.Streaming.IncludeCameraInOutput; - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { IncludeCameraInOutput = nextValue } - }; - foreach (var camera in _sceneCameras) { MediaSceneService.SetIncludeInOutput(camera.SourceId, nextValue); } await PersistSceneAsync(); - _studioSettings = _studioSettings with - { - Streaming = GoLiveDestinationRouting.Normalize(_studioSettings.Streaming, _sceneCameras) - }; - await PersistStudioSettingsAsync(); - } - - private async Task UpdateLiveKitServerSettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { LiveKitServerUrl = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task UpdateLiveKitRoomSettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { LiveKitRoomName = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task UpdateLiveKitTokenSettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { LiveKitToken = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task UpdateVdoRoomSettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { VdoNinjaRoomName = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task UpdateVdoPublishUrlSettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { VdoNinjaPublishUrl = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task UpdateYoutubeUrlSettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { YoutubeRtmpUrl = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); - } - - private async Task UpdateYoutubeKeySettingAsync(ChangeEventArgs args) - { - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with { YoutubeStreamKey = GetInputValue(args) } - }; - - await PersistStudioSettingsAsync(); + await UpdateStreamingSettingsAsync(streaming => streaming with { IncludeCameraInOutput = nextValue }); } - private async Task UpdateTwitchUrlSettingAsync(ChangeEventArgs args) + private async Task UpdateStreamingSettingsAsync( + Func update, + bool normalizeSources = true) { - _studioSettings = _studioSettings with + var nextStreaming = update(_studioSettings.Streaming); + if (normalizeSources) { - Streaming = _studioSettings.Streaming with { TwitchRtmpUrl = GetInputValue(args) } - }; + nextStreaming = GoLiveDestinationRouting.Normalize(nextStreaming, _sceneCameras); + } + _studioSettings = _studioSettings with { Streaming = nextStreaming }; await PersistStudioSettingsAsync(); } - private async Task UpdateTwitchKeySettingAsync(ChangeEventArgs args) + private static StreamStudioSettings UpdateExternalDestination( + StreamStudioSettings streaming, + string destinationId, + Func update) { - _studioSettings = _studioSettings with + return streaming with { - Streaming = _studioSettings.Streaming with { TwitchStreamKey = GetInputValue(args) } + ExternalDestinations = (streaming.ExternalDestinations ?? Array.Empty()) + .Select(destination => string.Equals(destination.Id, destinationId, StringComparison.Ordinal) + ? update(destination) + : destination) + .ToArray() }; - - await PersistStudioSettingsAsync(); } - private async Task UpdateCustomRtmpNameSettingAsync(ChangeEventArgs args) + private static StreamingProfile ApplyExternalDestinationField( + StreamingProfile destination, + string fieldId, + string value) { - var nextName = string.IsNullOrWhiteSpace(GetInputValue(args)) - ? StreamingDefaults.CustomTargetName - : GetInputValue(args); - - _studioSettings = _studioSettings with + return fieldId switch { - Streaming = _studioSettings.Streaming with { CustomRtmpName = nextName } + StreamingDestinationFieldIds.Name => ApplyDestinationName(destination, value), + StreamingDestinationFieldIds.ServerUrl => destination with { ServerUrl = value }, + StreamingDestinationFieldIds.RoomName => destination with { RoomName = value }, + StreamingDestinationFieldIds.Token => destination with { Token = value }, + StreamingDestinationFieldIds.PublishUrl => destination with { PublishUrl = value }, + StreamingDestinationFieldIds.RtmpUrl => destination.SetPrimaryDestination( + destination.Name, + value, + destination.GetPrimaryDestinationStreamKey()), + StreamingDestinationFieldIds.StreamKey => destination.SetPrimaryDestination( + destination.Name, + destination.GetPrimaryDestinationUrl(), + value), + _ => destination }; - - await PersistStudioSettingsAsync(); } - private async Task UpdateCustomRtmpUrlSettingAsync(ChangeEventArgs args) + private static StreamingProfile ApplyDestinationName(StreamingProfile destination, string value) { - var nextValue = GetInputValue(args); - _studioSettings = _studioSettings with - { - Streaming = _studioSettings.Streaming with - { - CustomRtmpUrl = nextValue, - RtmpUrl = nextValue - } - }; + var definition = StreamingPlatformCatalog.Get(destination.PlatformKind); + var name = string.IsNullOrWhiteSpace(value) + ? definition.DefaultProfileName + : value; - await PersistStudioSettingsAsync(); + return destination.ProviderKind == StreamingProviderKind.Rtmp + ? destination.SetPrimaryDestination( + name, + destination.GetPrimaryDestinationUrl(), + destination.GetPrimaryDestinationStreamKey()) + : destination with { Name = name }; } - private async Task UpdateCustomRtmpKeySettingAsync(ChangeEventArgs args) + private static string BuildDestinationDisplayName(StreamingPlatformKind kind, string destinationId) { - var nextValue = GetInputValue(args); - _studioSettings = _studioSettings with + var definition = StreamingPlatformCatalog.Get(kind); + if (string.Equals(destinationId, definition.IdPrefix, StringComparison.Ordinal)) { - Streaming = _studioSettings.Streaming with - { - CustomRtmpStreamKey = nextValue, - StreamKey = nextValue - } - }; + return definition.DefaultProfileName; + } - await PersistStudioSettingsAsync(); + var suffix = destinationId[(definition.IdPrefix.Length + 1)..]; + return $"{definition.DefaultProfileName} {suffix}"; } - - private static string GetInputValue(ChangeEventArgs args) => args.Value?.ToString() ?? string.Empty; } diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor index 39ec5ff..4d0baa0 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor @@ -428,33 +428,20 @@ Sources="@_sceneCameras" SelectedOutputModeValue="@SelectedStreamingOutputModeValue" IsCardOpen="IsCardOpen" + AddExternalDestination="AddExternalDestinationAsync" ToggleObs="ToggleObsOutputAsync" ToggleNdi="ToggleNdiOutputAsync" ToggleRecording="ToggleRecordingOutputAsync" - ToggleLiveKit="ToggleLiveKitSettingsAsync" - ToggleVdo="ToggleVdoSettingsAsync" - ToggleYoutube="ToggleYoutubeSettingsAsync" - ToggleTwitch="ToggleTwitchSettingsAsync" - ToggleCustomRtmp="ToggleCustomRtmpSettingsAsync" + ToggleExternalDestination="ToggleExternalDestinationAsync" + RemoveExternalDestination="RemoveExternalDestinationAsync" ToggleCard="TogglePreferenceCardAsync" ToggleDestinationSource="ToggleStreamingDestinationSourceAsync" + UpdateExternalDestinationField="UpdateExternalDestinationFieldAsync" OutputModeChanged="OnStreamingOutputModeChanged" OutputResolutionChanged="OnStreamingOutputResolutionChanged" BitrateChanged="UpdateStreamingBitrateAsync" ToggleTextOverlay="ToggleSettingsTextOverlayAsync" - ToggleIncludeCamera="ToggleSettingsIncludeCameraAsync" - LiveKitServerChanged="UpdateLiveKitServerSettingAsync" - LiveKitRoomChanged="UpdateLiveKitRoomSettingAsync" - LiveKitTokenChanged="UpdateLiveKitTokenSettingAsync" - VdoRoomChanged="UpdateVdoRoomSettingAsync" - VdoPublishUrlChanged="UpdateVdoPublishUrlSettingAsync" - YoutubeUrlChanged="UpdateYoutubeUrlSettingAsync" - YoutubeKeyChanged="UpdateYoutubeKeySettingAsync" - TwitchUrlChanged="UpdateTwitchUrlSettingAsync" - TwitchKeyChanged="UpdateTwitchKeySettingAsync" - CustomRtmpNameChanged="UpdateCustomRtmpNameSettingAsync" - CustomRtmpUrlChanged="UpdateCustomRtmpUrlSettingAsync" - CustomRtmpKeyChanged="UpdateCustomRtmpKeySettingAsync" /> + ToggleIncludeCamera="ToggleSettingsIncludeCameraAsync" /> sceneCameras) { - var streaming = settings.Streaming; - var hasModernTargets = streaming.ObsVirtualCameraEnabled + var normalizedStreaming = NormalizeLocalTargets(settings.Streaming); + normalizedStreaming = normalizedStreaming with + { + ExternalDestinations = NormalizeExternalDestinations(normalizedStreaming) + }; + normalizedStreaming = GoLiveDestinationRouting.Normalize(normalizedStreaming, sceneCameras); + return settings with { Streaming = normalizedStreaming }; + } + + private static StreamStudioSettings NormalizeLocalTargets(StreamStudioSettings streaming) + { + var normalizedStreaming = streaming with + { + CustomRtmpName = NormalizeCustomName(streaming.CustomRtmpName) + }; + + if (HasExplicitStreamingTargets(normalizedStreaming)) + { + return normalizedStreaming; + } + + var customRtmpUrl = string.IsNullOrWhiteSpace(normalizedStreaming.CustomRtmpUrl) + ? normalizedStreaming.RtmpUrl + : normalizedStreaming.CustomRtmpUrl; + var customRtmpKey = string.IsNullOrWhiteSpace(normalizedStreaming.CustomRtmpStreamKey) + ? normalizedStreaming.StreamKey + : normalizedStreaming.CustomRtmpStreamKey; + + return normalizedStreaming.OutputMode switch + { + StreamingOutputMode.VirtualCamera => normalizedStreaming with { ObsVirtualCameraEnabled = true }, + StreamingOutputMode.NdiOutput => normalizedStreaming with { NdiOutputEnabled = true }, + StreamingOutputMode.LocalRecording => normalizedStreaming with { LocalRecordingEnabled = true }, + StreamingOutputMode.DirectRtmp => normalizedStreaming with + { + CustomRtmpEnabled = !string.IsNullOrWhiteSpace(customRtmpUrl), + CustomRtmpUrl = customRtmpUrl, + CustomRtmpStreamKey = customRtmpKey + }, + _ => normalizedStreaming + }; + } + + private static bool HasExplicitStreamingTargets(StreamStudioSettings streaming) + { + return streaming.ObsVirtualCameraEnabled || streaming.NdiOutputEnabled || streaming.LocalRecordingEnabled - || streaming.LiveKitEnabled + || HasExternalDestinationData(streaming); + } + + private static bool HasExternalDestinationData(StreamStudioSettings streaming) + { + if (streaming.ExternalDestinations is { Count: > 0 }) + { + return true; + } + + return streaming.LiveKitEnabled || streaming.VdoNinjaEnabled || streaming.YoutubeEnabled || streaming.TwitchEnabled - || streaming.CustomRtmpEnabled; + || streaming.CustomRtmpEnabled + || HasAnyValue(streaming.LiveKitServerUrl, streaming.LiveKitRoomName, streaming.LiveKitToken) + || HasAnyValue(streaming.VdoNinjaRoomName, streaming.VdoNinjaPublishUrl) + || HasAnyValue(streaming.YoutubeRtmpUrl, streaming.YoutubeStreamKey) + || HasAnyValue(streaming.TwitchRtmpUrl, streaming.TwitchStreamKey) + || HasAnyValue(streaming.CustomRtmpUrl, streaming.CustomRtmpStreamKey, streaming.RtmpUrl, streaming.StreamKey); + } - StreamStudioSettings normalizedStreaming; - if (hasModernTargets) + private static IReadOnlyList NormalizeExternalDestinations(StreamStudioSettings streaming) + { + var existingDestinations = streaming.ExternalDestinations ?? Array.Empty(); + if (existingDestinations.Count > 0) { - normalizedStreaming = streaming with + return existingDestinations + .Select(NormalizeExternalDestination) + .ToArray(); + } + + return BuildLegacyExternalDestinations(streaming); + } + + private static IReadOnlyList BuildLegacyExternalDestinations(StreamStudioSettings streaming) + { + var destinations = new List(); + + AddLegacyDestination( + destinations, + GoLiveTargetCatalog.TargetIds.LiveKit, + StreamingPlatformKind.LiveKit, + streaming.LiveKitEnabled || HasAnyValue(streaming.LiveKitServerUrl, streaming.LiveKitRoomName, streaming.LiveKitToken), + profile => profile with + { + IsEnabled = streaming.LiveKitEnabled, + ServerUrl = streaming.LiveKitServerUrl, + RoomName = streaming.LiveKitRoomName, + Token = streaming.LiveKitToken + }); + + AddLegacyDestination( + destinations, + GoLiveTargetCatalog.TargetIds.VdoNinja, + StreamingPlatformKind.VdoNinja, + streaming.VdoNinjaEnabled || HasAnyValue(streaming.VdoNinjaRoomName, streaming.VdoNinjaPublishUrl), + profile => profile with + { + IsEnabled = streaming.VdoNinjaEnabled, + RoomName = streaming.VdoNinjaRoomName, + PublishUrl = streaming.VdoNinjaPublishUrl + }); + + AddLegacyDestination( + destinations, + GoLiveTargetCatalog.TargetIds.Youtube, + StreamingPlatformKind.Youtube, + streaming.YoutubeEnabled || HasAnyValue(streaming.YoutubeRtmpUrl, streaming.YoutubeStreamKey), + profile => + { + var enabledProfile = profile with + { + IsEnabled = streaming.YoutubeEnabled + }; + + return enabledProfile.SetPrimaryDestination( + StreamingPlatformCatalog.Get(StreamingPlatformKind.Youtube).DefaultProfileName, + string.IsNullOrWhiteSpace(streaming.YoutubeRtmpUrl) + ? StreamingPlatformCatalog.Get(StreamingPlatformKind.Youtube).DefaultRtmpUrl + : streaming.YoutubeRtmpUrl, + streaming.YoutubeStreamKey); + }); + + AddLegacyDestination( + destinations, + GoLiveTargetCatalog.TargetIds.Twitch, + StreamingPlatformKind.Twitch, + streaming.TwitchEnabled || HasAnyValue(streaming.TwitchRtmpUrl, streaming.TwitchStreamKey), + profile => { - CustomRtmpName = string.IsNullOrWhiteSpace(streaming.CustomRtmpName) - ? StreamingDefaults.CustomTargetName - : streaming.CustomRtmpName + var enabledProfile = profile with + { + IsEnabled = streaming.TwitchEnabled + }; + + return enabledProfile.SetPrimaryDestination( + StreamingPlatformCatalog.Get(StreamingPlatformKind.Twitch).DefaultProfileName, + string.IsNullOrWhiteSpace(streaming.TwitchRtmpUrl) + ? StreamingPlatformCatalog.Get(StreamingPlatformKind.Twitch).DefaultRtmpUrl + : streaming.TwitchRtmpUrl, + streaming.TwitchStreamKey); + }); + + var customRtmpUrl = string.IsNullOrWhiteSpace(streaming.CustomRtmpUrl) + ? streaming.RtmpUrl + : streaming.CustomRtmpUrl; + var customRtmpKey = string.IsNullOrWhiteSpace(streaming.CustomRtmpStreamKey) + ? streaming.StreamKey + : streaming.CustomRtmpStreamKey; + var customRtmpName = NormalizeCustomName(streaming.CustomRtmpName); + + AddLegacyDestination( + destinations, + GoLiveTargetCatalog.TargetIds.CustomRtmp, + StreamingPlatformKind.CustomRtmp, + streaming.CustomRtmpEnabled || HasAnyValue(customRtmpUrl, customRtmpKey), + profile => + { + var enabledProfile = profile with + { + IsEnabled = streaming.CustomRtmpEnabled, + Name = customRtmpName + }; + + return enabledProfile.SetPrimaryDestination(customRtmpName, customRtmpUrl, customRtmpKey); + }); + + return destinations + .Select(NormalizeExternalDestination) + .ToArray(); + } + + private static void AddLegacyDestination( + ICollection destinations, + string id, + StreamingPlatformKind kind, + bool shouldInclude, + Func update) + { + if (!shouldInclude) + { + return; + } + + var profile = StreamingPlatformCatalog.CreateProfile(kind, id); + destinations.Add(update(profile)); + } + + private static StreamingProfile NormalizeExternalDestination(StreamingProfile profile) + { + var kind = ResolvePlatformKind(profile); + var definition = StreamingPlatformCatalog.Get(kind); + var name = string.IsNullOrWhiteSpace(profile.Name) + ? definition.DefaultProfileName + : profile.Name; + + if (definition.ProviderKind != StreamingProviderKind.Rtmp) + { + return profile with + { + Name = name, + PlatformKind = kind, + ProviderKind = definition.ProviderKind, + Destinations = Array.Empty() }; } - else + + var primaryDestination = profile.GetPrimaryDestination(); + var destinationName = string.IsNullOrWhiteSpace(primaryDestination.Name) + ? name + : primaryDestination.Name; + var destinationUrl = string.IsNullOrWhiteSpace(primaryDestination.Url) + ? definition.DefaultRtmpUrl + : primaryDestination.Url; + + return profile with + { + Name = name, + PlatformKind = kind, + ProviderKind = definition.ProviderKind, + Destinations = + [ + new StreamingDestination( + destinationName, + destinationUrl, + primaryDestination.StreamKey, + primaryDestination.IsEnabled) + ] + }; + } + + private static StreamingPlatformKind ResolvePlatformKind(StreamingProfile profile) + { + if (TryResolvePlatformKind(profile.Id, out var kind)) { - var customRtmpUrl = string.IsNullOrWhiteSpace(streaming.CustomRtmpUrl) - ? streaming.RtmpUrl - : streaming.CustomRtmpUrl; - var customRtmpKey = string.IsNullOrWhiteSpace(streaming.CustomRtmpStreamKey) - ? streaming.StreamKey - : streaming.CustomRtmpStreamKey; + return kind; + } - normalizedStreaming = streaming.OutputMode switch + return profile.ProviderKind switch + { + StreamingProviderKind.LiveKit => StreamingPlatformKind.LiveKit, + StreamingProviderKind.VdoNinja => StreamingPlatformKind.VdoNinja, + _ => profile.PlatformKind is StreamingPlatformKind.Youtube + or StreamingPlatformKind.Twitch + or StreamingPlatformKind.CustomRtmp + ? profile.PlatformKind + : StreamingPlatformKind.CustomRtmp + }; + } + + private static bool TryResolvePlatformKind(string destinationId, out StreamingPlatformKind kind) + { + foreach (var definition in StreamingPlatformCatalog.All) + { + if (string.Equals(destinationId, definition.IdPrefix, StringComparison.Ordinal) + || destinationId.StartsWith($"{definition.IdPrefix}-", StringComparison.Ordinal)) { - StreamingOutputMode.VirtualCamera => streaming with { ObsVirtualCameraEnabled = true }, - StreamingOutputMode.NdiOutput => streaming with { NdiOutputEnabled = true }, - StreamingOutputMode.LocalRecording => streaming with { LocalRecordingEnabled = true }, - StreamingOutputMode.DirectRtmp => streaming with - { - CustomRtmpEnabled = !string.IsNullOrWhiteSpace(customRtmpUrl), - CustomRtmpName = string.IsNullOrWhiteSpace(streaming.CustomRtmpName) - ? StreamingDefaults.CustomTargetName - : streaming.CustomRtmpName, - CustomRtmpUrl = customRtmpUrl, - CustomRtmpStreamKey = customRtmpKey - }, - _ => streaming - }; + kind = definition.Kind; + return true; + } } - normalizedStreaming = GoLiveDestinationRouting.Normalize(normalizedStreaming, sceneCameras); - return settings with { Streaming = normalizedStreaming }; + kind = default; + return false; } + + private static bool HasAnyValue(params string?[] values) => + values.Any(value => !string.IsNullOrWhiteSpace(value)); + + private static string NormalizeCustomName(string? value) => + string.IsNullOrWhiteSpace(value) + ? StreamingDefaults.CustomTargetName + : value; } diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs index 3887b62..2048079 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs @@ -18,6 +18,7 @@ public partial class TeleprompterPage private const int MinimumReaderBaseWpm = 80; private const int MinimumReaderWordDurationMilliseconds = 120; private const int MinimumPauseDurationMilliseconds = 250; + private const string NeutralEmotionKey = "neutral"; private const string ReaderPauseCssClass = "rd-pause"; private const string ReaderPauseLongCssClass = "rd-pause rd-pause-long"; private const string ReaderPauseMediumCssClass = "rd-pause rd-pause-med"; @@ -342,10 +343,10 @@ private static string ResolveReaderBackgroundClass(string emotionKey) => _ => emotionKey }; - private static string ResolveEmotionKey(string? emotion) + private static string ResolveEmotionKey(string? emotion, string fallbackEmotionKey = NeutralEmotionKey) { var normalized = string.IsNullOrWhiteSpace(emotion) - ? "neutral" + ? fallbackEmotionKey : emotion.Trim().ToLowerInvariant(); return normalized switch @@ -358,9 +359,9 @@ private static string ResolveEmotionKey(string? emotion) "grateful" => "warm", _ => normalized switch { - "warm" or "concerned" or "focused" or "motivational" or "neutral" or "urgent" or + "warm" or "concerned" or "focused" or "motivational" or NeutralEmotionKey or "urgent" or "happy" or "excited" or "sad" or "calm" or "energetic" or "professional" => normalized, - _ => "neutral" + _ => fallbackEmotionKey } }; } @@ -394,7 +395,7 @@ private static string ResolveColorClass(string? color, string prefix) private static string ResolveEmotionWordClass(string? emotion, string prefix) { - var emotionKey = ResolveEmotionKey(emotion); + var emotionKey = ResolveEmotionKey(emotion, string.Empty); return string.IsNullOrWhiteSpace(emotionKey) ? string.Empty : $"{prefix}-{emotionKey}"; } diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs index 8d83b14..a302c18 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs @@ -31,23 +31,7 @@ private sealed record ReaderCardViewModel( string WidthPercentString, string EdgeColor, IReadOnlyList Chunks, - string TestId) - { - public static ReaderCardViewModel Empty { get; } = new( - SectionName: string.Empty, - DisplayName: string.Empty, - EmotionKey: "warm", - EmotionLabel: string.Empty, - BackgroundClass: "warm", - AccentColor: "#E97F00", - TargetWpm: 0, - WordCount: 0, - DurationMilliseconds: 0, - WidthPercentString: "0%", - EdgeColor: "rgba(233, 127, 0, 0.35)", - Chunks: [], - TestId: UiTestIds.Teleprompter.Card(999)); - } + string TestId); private abstract record ReaderChunkViewModel; diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor index 5cf659d..2c7553b 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor @@ -2,10 +2,6 @@ @namespace PrompterOne.Shared.Pages @using PrompterOne.Shared.Contracts - - - -
0 ? nextCards : [ReaderCardViewModel.Empty]; + _cards = nextCards; _screenTitle = SessionService.State.Title; _readerFontSize = NormalizeReaderFontSize(SessionService.State.ReaderSettings.FontScale); _readerFocalPointPercent = NormalizeReaderFocalPointPercent(SessionService.State.ReaderSettings.FocalPointPercent); diff --git a/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css b/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css index f4977fa..5612431 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/reader/00-shell.css @@ -180,19 +180,19 @@ } .rd-cluster b { font-weight: 700; } -/* ── TPS Inline Colors (per TPS_FORMAT.md dark-BG spec) ── */ -.tps-red { color: #FF6B6B; } -.tps-green { color: #51CF66; } -.tps-blue { color: #74C0FC; } -.tps-yellow { color: #FFE066; } -.tps-orange { color: #FFA94D; } -.tps-purple { color: #CC5DE8; } -.tps-cyan { color: #66D9E8; } -.tps-magenta { color: #F783AC; } -.tps-pink { color: #FAA2C1; } -.tps-teal { color: #38D9A9; } -.tps-white { color: #F8F9FA; } -.tps-gray { color: #ADB5BD; } +/* ── TPS Inline Colors — keep semantic contrast without neon spill ── */ +.tps-red { color: #F19A9A; } +.tps-green { color: #9FD7AD; } +.tps-blue { color: #ACCBE6; } +.tps-yellow { color: #E8D58E; } +.tps-orange { color: #E4B07A; } +.tps-purple { color: #C5A4DD; } +.tps-cyan { color: #93CDD7; } +.tps-magenta { color: #D9A5BF; } +.tps-pink { color: #DEB1C5; } +.tps-teal { color: #8ECDBE; } +.tps-white { color: #F2E6CC; } +.tps-gray { color: #BBB8B0; } /* ── TPS Highlight — yellow background overlay ── */ .tps-highlight { @@ -208,18 +208,18 @@ .tps-emphasis { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 4px; text-decoration-thickness: 2px; } /* ── TPS Emotion Inline Colors ── */ -.tps-warm { color: #FFA94D; } -.tps-concerned { color: #FF6B6B; } -.tps-focused { color: #51CF66; } -.tps-motivational { color: #CC5DE8; } -.tps-neutral { color: #74C0FC; } -.tps-urgent { color: #FF4444; font-weight: 900; } -.tps-happy { color: #FFE066; } -.tps-excited { color: #FAA2C1; } -.tps-sad { color: #7B68EE; } -.tps-calm { color: #38D9A9; } -.tps-energetic { color: #FF6347; } -.tps-professional { color: #5B7FFF; } +.tps-warm { color: #E4B07A; } +.tps-concerned { color: #E49A9A; } +.tps-focused { color: #9FD7AD; } +.tps-motivational { color: #C5A4DD; } +.tps-neutral { color: #ACCBE6; } +.tps-urgent { color: #E58A8A; font-weight: 800; } +.tps-happy { color: #E8D58E; } +.tps-excited { color: #DEB1C5; } +.tps-sad { color: #A59DDC; } +.tps-calm { color: #8ECDBE; } +.tps-energetic { color: #E1A581; } +.tps-professional { color: #A9BFE7; } /* ── TPS Speed Indicators (visual cue for speed tags) ── */ .tps-xslow { letter-spacing: var(--tps-word-letter-spacing, 0.085em); } diff --git a/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css b/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css index 9e1b8a3..e399a52 100644 --- a/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css +++ b/src/PrompterOne.Shared/wwwroot/design/modules/reader/10-reading-states.css @@ -89,7 +89,7 @@ font-size: var(--rd-font-size, 36px); font-weight: 700; line-height: 1.9; - color: rgba(232,213,176,.45); + color: rgba(242,230,204,.34); letter-spacing: 0.01em; word-spacing: 0.08em; overflow-wrap: break-word; @@ -229,36 +229,36 @@ span.rd-pause.rd-pause-long { preview upcoming words within the phrase while the speaker reads. 4-level hierarchy: read (15%) → upcoming (40%) → active group (75%) → current word (100%) */ .rd-g-active > .rd-w:not(.rd-now):not(.rd-read) { - color: rgba(232,213,176,.75); - transition: color .12s ease; + color: rgba(242,230,204,.58); + transition: color .1s ease; } /* Preserve TPS colors inside active group */ -.rd-g-active > .rd-w.tps-red:not(.rd-now):not(.rd-read) { color: rgba(255,107,107,.75); } -.rd-g-active > .rd-w.tps-green:not(.rd-now):not(.rd-read) { color: rgba(81,207,102,.75); } -.rd-g-active > .rd-w.tps-blue:not(.rd-now):not(.rd-read) { color: rgba(116,192,252,.75); } -.rd-g-active > .rd-w.tps-yellow:not(.rd-now):not(.rd-read) { color: rgba(255,224,102,.75); } -.rd-g-active > .rd-w.tps-orange:not(.rd-now):not(.rd-read) { color: rgba(255,169,77,.75); } -.rd-g-active > .rd-w.tps-purple:not(.rd-now):not(.rd-read) { color: rgba(204,93,232,.75); } -.rd-g-active > .rd-w.tps-cyan:not(.rd-now):not(.rd-read) { color: rgba(102,217,232,.75); } -.rd-g-active > .rd-w.tps-magenta:not(.rd-now):not(.rd-read) { color: rgba(247,131,172,.75); } -.rd-g-active > .rd-w.tps-pink:not(.rd-now):not(.rd-read) { color: rgba(250,162,193,.75); } -.rd-g-active > .rd-w.tps-teal:not(.rd-now):not(.rd-read) { color: rgba(56,217,169,.75); } -.rd-g-active > .rd-w.tps-white:not(.rd-now):not(.rd-read) { color: rgba(248,249,250,.78); } -.rd-g-active > .rd-w.tps-gray:not(.rd-now):not(.rd-read) { color: rgba(173,181,189,.72); } -.rd-g-active > .rd-w.tps-warm:not(.rd-now):not(.rd-read) { color: rgba(255,169,77,.75); } -.rd-g-active > .rd-w.tps-concerned:not(.rd-now):not(.rd-read) { color: rgba(255,107,107,.75); } -.rd-g-active > .rd-w.tps-focused:not(.rd-now):not(.rd-read) { color: rgba(81,207,102,.75); } -.rd-g-active > .rd-w.tps-motivational:not(.rd-now):not(.rd-read) { color: rgba(204,93,232,.75); } -.rd-g-active > .rd-w.tps-neutral:not(.rd-now):not(.rd-read) { color: rgba(116,192,252,.75); } -.rd-g-active > .rd-w.tps-urgent:not(.rd-now):not(.rd-read) { color: rgba(255,68,68,.82); } -.rd-g-active > .rd-w.tps-happy:not(.rd-now):not(.rd-read) { color: rgba(255,224,102,.75); } -.rd-g-active > .rd-w.tps-excited:not(.rd-now):not(.rd-read) { color: rgba(250,162,193,.75); } -.rd-g-active > .rd-w.tps-sad:not(.rd-now):not(.rd-read) { color: rgba(123,104,238,.75); } -.rd-g-active > .rd-w.tps-calm:not(.rd-now):not(.rd-read) { color: rgba(56,217,169,.75); } -.rd-g-active > .rd-w.tps-energetic:not(.rd-now):not(.rd-read) { color: rgba(255,99,71,.78); } -.rd-g-active > .rd-w.tps-professional:not(.rd-now):not(.rd-read) { color: rgba(91,127,255,.78); } -.rd-g-active.rd-g-emphasis { text-decoration-color: rgba(232,213,176,.45); } -.rd-g-active > .rd-w.tps-highlight:not(.rd-now):not(.rd-read) { background: rgba(255,224,102,.18); } +.rd-g-active > .rd-w.tps-red:not(.rd-now):not(.rd-read) { color: rgba(241,154,154,.66); } +.rd-g-active > .rd-w.tps-green:not(.rd-now):not(.rd-read) { color: rgba(159,215,173,.66); } +.rd-g-active > .rd-w.tps-blue:not(.rd-now):not(.rd-read) { color: rgba(172,203,230,.66); } +.rd-g-active > .rd-w.tps-yellow:not(.rd-now):not(.rd-read) { color: rgba(232,213,142,.66); } +.rd-g-active > .rd-w.tps-orange:not(.rd-now):not(.rd-read) { color: rgba(228,176,122,.66); } +.rd-g-active > .rd-w.tps-purple:not(.rd-now):not(.rd-read) { color: rgba(197,164,221,.66); } +.rd-g-active > .rd-w.tps-cyan:not(.rd-now):not(.rd-read) { color: rgba(147,205,215,.66); } +.rd-g-active > .rd-w.tps-magenta:not(.rd-now):not(.rd-read) { color: rgba(217,165,191,.66); } +.rd-g-active > .rd-w.tps-pink:not(.rd-now):not(.rd-read) { color: rgba(222,177,197,.66); } +.rd-g-active > .rd-w.tps-teal:not(.rd-now):not(.rd-read) { color: rgba(142,205,190,.66); } +.rd-g-active > .rd-w.tps-white:not(.rd-now):not(.rd-read) { color: rgba(242,230,204,.68); } +.rd-g-active > .rd-w.tps-gray:not(.rd-now):not(.rd-read) { color: rgba(187,184,176,.64); } +.rd-g-active > .rd-w.tps-warm:not(.rd-now):not(.rd-read) { color: rgba(228,176,122,.66); } +.rd-g-active > .rd-w.tps-concerned:not(.rd-now):not(.rd-read) { color: rgba(228,154,154,.66); } +.rd-g-active > .rd-w.tps-focused:not(.rd-now):not(.rd-read) { color: rgba(159,215,173,.66); } +.rd-g-active > .rd-w.tps-motivational:not(.rd-now):not(.rd-read) { color: rgba(197,164,221,.66); } +.rd-g-active > .rd-w.tps-neutral:not(.rd-now):not(.rd-read) { color: rgba(172,203,230,.66); } +.rd-g-active > .rd-w.tps-urgent:not(.rd-now):not(.rd-read) { color: rgba(229,138,138,.7); } +.rd-g-active > .rd-w.tps-happy:not(.rd-now):not(.rd-read) { color: rgba(232,213,142,.66); } +.rd-g-active > .rd-w.tps-excited:not(.rd-now):not(.rd-read) { color: rgba(222,177,197,.66); } +.rd-g-active > .rd-w.tps-sad:not(.rd-now):not(.rd-read) { color: rgba(165,157,220,.66); } +.rd-g-active > .rd-w.tps-calm:not(.rd-now):not(.rd-read) { color: rgba(142,205,190,.66); } +.rd-g-active > .rd-w.tps-energetic:not(.rd-now):not(.rd-read) { color: rgba(225,165,129,.68); } +.rd-g-active > .rd-w.tps-professional:not(.rd-now):not(.rd-read) { color: rgba(169,191,231,.68); } +.rd-g-active.rd-g-emphasis { text-decoration-color: rgba(242,230,204,.3); } +.rd-g-active > .rd-w.tps-highlight:not(.rd-now):not(.rd-read) { background: rgba(232,213,142,.12); } /* Word states — NO font-size changes to prevent layout jumping */ .rd-w { @@ -273,7 +273,7 @@ span.rd-pause.rd-pause-long { and breaks the reader's positional memory. Only change visual properties (color, shadow, decoration) that don't affect layout. */ .rd-w.rd-read { - color: rgba(232,213,176,.15) !important; + color: rgba(242,230,204,.12) !important; text-shadow: none !important; text-decoration: none !important; background: none !important; @@ -283,39 +283,39 @@ span.rd-pause.rd-pause-long { /* Active word — underline accent, preserves TPS inline color */ .rd-w.rd-now { - color: #F2E6CC; - text-shadow: 0 0 30px rgba(232,213,176,.15), 0 0 60px rgba(232,130,92,.1); + color: rgba(242,230,204,.96); + text-shadow: 0 0 10px rgba(242,230,204,.08), 0 0 18px rgba(232,130,92,.04); text-decoration: underline; - text-decoration-color: rgba(232,213,176,.35); + text-decoration-color: rgba(242,230,204,.22); text-underline-offset: 8px; text-decoration-thickness: 2px; } -/* Active word with TPS color — boost the color */ -.rd-w.rd-now.tps-red { color: #FF6B6B; text-shadow: 0 0 40px rgba(255,107,107,.3); } -.rd-w.rd-now.tps-green { color: #51CF66; text-shadow: 0 0 40px rgba(81,207,102,.3); } -.rd-w.rd-now.tps-blue { color: #74C0FC; text-shadow: 0 0 40px rgba(116,192,252,.3); } -.rd-w.rd-now.tps-yellow { color: #FFE066; text-shadow: 0 0 40px rgba(255,224,102,.3); } -.rd-w.rd-now.tps-orange { color: #FFA94D; text-shadow: 0 0 40px rgba(255,169,77,.3); } -.rd-w.rd-now.tps-purple { color: #CC5DE8; text-shadow: 0 0 40px rgba(204,93,232,.3); } -.rd-w.rd-now.tps-cyan { color: #66D9E8; text-shadow: 0 0 40px rgba(102,217,232,.3); } -.rd-w.rd-now.tps-magenta { color: #F783AC; text-shadow: 0 0 40px rgba(247,131,172,.3); } -.rd-w.rd-now.tps-pink { color: #FAA2C1; text-shadow: 0 0 40px rgba(250,162,193,.3); } -.rd-w.rd-now.tps-teal { color: #38D9A9; text-shadow: 0 0 40px rgba(56,217,169,.3); } -.rd-w.rd-now.tps-white { color: #F8F9FA; text-shadow: 0 0 40px rgba(248,249,250,.25); } -.rd-w.rd-now.tps-gray { color: #ADB5BD; text-shadow: 0 0 40px rgba(173,181,189,.25); } -.rd-w.rd-now.tps-warm { color: #FFA94D; text-shadow: 0 0 40px rgba(255,169,77,.3); } -.rd-w.rd-now.tps-concerned { color: #FF6B6B; text-shadow: 0 0 40px rgba(255,107,107,.3); } -.rd-w.rd-now.tps-focused { color: #51CF66; text-shadow: 0 0 40px rgba(81,207,102,.3); } -.rd-w.rd-now.tps-motivational { color: #CC5DE8; text-shadow: 0 0 40px rgba(204,93,232,.3); } -.rd-w.rd-now.tps-neutral { color: #74C0FC; text-shadow: 0 0 40px rgba(116,192,252,.3); } -.rd-w.rd-now.tps-urgent { color: #FF4444; text-shadow: 0 0 40px rgba(255,68,68,.32); } -.rd-w.rd-now.tps-happy { color: #FFE066; text-shadow: 0 0 40px rgba(255,224,102,.3); } -.rd-w.rd-now.tps-excited { color: #FAA2C1; text-shadow: 0 0 40px rgba(250,162,193,.3); } -.rd-w.rd-now.tps-sad { color: #7B68EE; text-shadow: 0 0 40px rgba(123,104,238,.3); } -.rd-w.rd-now.tps-calm { color: #38D9A9; text-shadow: 0 0 40px rgba(56,217,169,.3); } -.rd-w.rd-now.tps-energetic { color: #FF6347; text-shadow: 0 0 40px rgba(255,99,71,.3); } -.rd-w.rd-now.tps-professional { color: #5B7FFF; text-shadow: 0 0 40px rgba(91,127,255,.3); } +/* Active word with TPS color — keep contrast but avoid neon glow */ +.rd-w.rd-now.tps-red { color: #F4B2B2; text-shadow: 0 0 14px rgba(241,154,154,.12); } +.rd-w.rd-now.tps-green { color: #B2E0BC; text-shadow: 0 0 14px rgba(159,215,173,.12); } +.rd-w.rd-now.tps-blue { color: #BDD5EA; text-shadow: 0 0 14px rgba(172,203,230,.12); } +.rd-w.rd-now.tps-yellow { color: #ECDCA1; text-shadow: 0 0 14px rgba(232,213,142,.12); } +.rd-w.rd-now.tps-orange { color: #E9BB8D; text-shadow: 0 0 14px rgba(228,176,122,.12); } +.rd-w.rd-now.tps-purple { color: #D2B8E3; text-shadow: 0 0 14px rgba(197,164,221,.12); } +.rd-w.rd-now.tps-cyan { color: #A9D7DE; text-shadow: 0 0 14px rgba(147,205,215,.12); } +.rd-w.rd-now.tps-magenta { color: #E0B5CA; text-shadow: 0 0 14px rgba(217,165,191,.12); } +.rd-w.rd-now.tps-pink { color: #E3BED0; text-shadow: 0 0 14px rgba(222,177,197,.12); } +.rd-w.rd-now.tps-teal { color: #A5D8CD; text-shadow: 0 0 14px rgba(142,205,190,.12); } +.rd-w.rd-now.tps-white { color: #F6ECD8; text-shadow: 0 0 12px rgba(242,230,204,.1); } +.rd-w.rd-now.tps-gray { color: #C4C0BA; text-shadow: 0 0 12px rgba(187,184,176,.1); } +.rd-w.rd-now.tps-warm { color: #E9BB8D; text-shadow: 0 0 14px rgba(228,176,122,.12); } +.rd-w.rd-now.tps-concerned { color: #ECB0B0; text-shadow: 0 0 14px rgba(228,154,154,.12); } +.rd-w.rd-now.tps-focused { color: #B2E0BC; text-shadow: 0 0 14px rgba(159,215,173,.12); } +.rd-w.rd-now.tps-motivational { color: #D2B8E3; text-shadow: 0 0 14px rgba(197,164,221,.12); } +.rd-w.rd-now.tps-neutral { color: #BDD5EA; text-shadow: 0 0 14px rgba(172,203,230,.12); } +.rd-w.rd-now.tps-urgent { color: #EEAAAA; text-shadow: 0 0 14px rgba(229,138,138,.14); } +.rd-w.rd-now.tps-happy { color: #ECDCA1; text-shadow: 0 0 14px rgba(232,213,142,.12); } +.rd-w.rd-now.tps-excited { color: #E3BED0; text-shadow: 0 0 14px rgba(222,177,197,.12); } +.rd-w.rd-now.tps-sad { color: #B5AFE4; text-shadow: 0 0 14px rgba(165,157,220,.12); } +.rd-w.rd-now.tps-calm { color: #A5D8CD; text-shadow: 0 0 14px rgba(142,205,190,.12); } +.rd-w.rd-now.tps-energetic { color: #E8B794; text-shadow: 0 0 14px rgba(225,165,129,.12); } +.rd-w.rd-now.tps-professional { color: #BECCE9; text-shadow: 0 0 14px rgba(169,191,231,.12); } /* Active word with strong emphasis */ .rd-g-emphasis .rd-w.rd-now { @@ -324,5 +324,5 @@ span.rd-pause.rd-pause-long { .rd-w.rd-now.tps-strong { text-decoration-color: var(--accent); - text-shadow: 0 0 50px rgba(232,130,92,.22); + text-shadow: 0 0 18px rgba(232,130,92,.12); } diff --git a/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js b/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js index 42f6203..af8037a 100644 --- a/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js +++ b/src/PrompterOne.Shared/wwwroot/learn/learn-rsvp-layout.js @@ -1,10 +1,5 @@ (function () { const interopNamespace = "LearnRsvpLayoutInterop"; - const focusSelector = ".rsvp-focus"; - const orpSelector = ".rsvp-focus-orp"; - const focusLeftExtentPropertyName = "--rsvp-focus-left-extent"; - const focusRightExtentPropertyName = "--rsvp-focus-right-extent"; - const fontSyncReadyAttributeName = "data-rsvp-layout-font-sync-ready"; const pixelUnitSuffix = "px"; const zeroPixels = "0px"; @@ -13,21 +8,18 @@ target.style.setProperty(propertyName, `${roundedValue}${pixelUnitSuffix}`); } - function applyDefaultLayout(rowElement) { + function applyDefaultLayout(rowElement, focusLeftExtentPropertyName, focusRightExtentPropertyName) { rowElement.style.setProperty(focusLeftExtentPropertyName, zeroPixels); rowElement.style.setProperty(focusRightExtentPropertyName, zeroPixels); } - function syncLayoutNow(rowElement) { - const focusElement = rowElement.querySelector(focusSelector); - const orpElement = focusElement?.querySelector(orpSelector); - + function syncLayoutNow(rowElement, focusElement, orpElement, focusLeftExtentPropertyName, focusRightExtentPropertyName) { if (!(focusElement instanceof HTMLElement) || !(orpElement instanceof HTMLElement)) { - applyDefaultLayout(rowElement); + applyDefaultLayout(rowElement, focusLeftExtentPropertyName, focusRightExtentPropertyName); return; } - applyDefaultLayout(rowElement); + applyDefaultLayout(rowElement, focusLeftExtentPropertyName, focusRightExtentPropertyName); void rowElement.offsetWidth; const focusRect = focusElement.getBoundingClientRect(); @@ -40,7 +32,7 @@ setPixelProperty(rowElement, focusRightExtentPropertyName, focusRightExtentPx); } - function scheduleFontReadySync(rowElement) { + function scheduleFontReadySync(rowElement, focusElement, orpElement, focusLeftExtentPropertyName, focusRightExtentPropertyName, fontSyncReadyAttributeName) { if (rowElement.getAttribute(fontSyncReadyAttributeName) === "true") { return; } @@ -53,19 +45,19 @@ void document.fonts.ready.then(() => { if (rowElement.isConnected) { - syncLayoutNow(rowElement); + syncLayoutNow(rowElement, focusElement, orpElement, focusLeftExtentPropertyName, focusRightExtentPropertyName); } }); } window[interopNamespace] = { - syncLayout(rowElement) { + syncLayout(rowElement, focusElement, orpElement, focusLeftExtentPropertyName, focusRightExtentPropertyName, fontSyncReadyAttributeName) { if (!(rowElement instanceof HTMLElement)) { return; } - syncLayoutNow(rowElement); - scheduleFontReadySync(rowElement); + syncLayoutNow(rowElement, focusElement, orpElement, focusLeftExtentPropertyName, focusRightExtentPropertyName); + scheduleFontReadySync(rowElement, focusElement, orpElement, focusLeftExtentPropertyName, focusRightExtentPropertyName, fontSyncReadyAttributeName); } }; })(); diff --git a/src/PrompterOne.Shared/wwwroot/media/browser-media.js b/src/PrompterOne.Shared/wwwroot/media/browser-media.js index 9c0ad2b..7efe0d8 100644 --- a/src/PrompterOne.Shared/wwwroot/media/browser-media.js +++ b/src/PrompterOne.Shared/wwwroot/media/browser-media.js @@ -3,11 +3,6 @@ const audioOutputKind = "audiooutput"; const cameraTrackMap = new Map(); const defaultDeviceId = "default"; - const fallbackDeviceNames = { - [audioInputKind]: "Microphone", - [audioOutputKind]: "Speaker", - videoinput: "Camera" - }; const interopNamespace = "BrowserMediaInterop"; const liveKitClientGlobal = "LivekitClient"; const microphoneMonitorLevelMultiplier = 2800; @@ -217,14 +212,17 @@ return element instanceof HTMLVideoElement ? element : null; } - function normalizeDevice(device, index, kind) { - const label = device.label && device.label.trim().length > 0 - ? device.label - : `${fallbackDeviceNames[kind] ?? "Device"} ${index + 1}`; + function normalizeDevice(device, kind) { + const deviceId = typeof device?.deviceId === "string" + ? device.deviceId + : ""; + const label = typeof device?.label === "string" + ? device.label.trim() + : ""; return { - deviceId: device.deviceId || defaultDeviceId, - isDefault: device.deviceId === defaultDeviceId, + deviceId, + isDefault: device?.isDefault === true || deviceId === defaultDeviceId, kind, label }; @@ -355,7 +353,7 @@ const groups = await Promise.all(orderedKinds.map(loadDevicesForKind)); return groups.flatMap((devices, groupIndex) => - devices.map((device, index) => normalizeDevice(device, index, orderedKinds[groupIndex]))); + devices.map(device => normalizeDevice(device, orderedKinds[groupIndex]))); }, async attachCamera(elementId, deviceId, muted) { diff --git a/tests/PrompterOne.App.Tests/AppShell/AppBootstrapperLearnSettingsTests.cs b/tests/PrompterOne.App.Tests/AppShell/AppBootstrapperLearnSettingsTests.cs new file mode 100644 index 0000000..11fd4ee --- /dev/null +++ b/tests/PrompterOne.App.Tests/AppShell/AppBootstrapperLearnSettingsTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using PrompterOne.Core.Abstractions; +using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Storage; +using PrompterOne.Shared.Tests; + +namespace PrompterOne.App.Tests; + +public sealed class AppBootstrapperLearnSettingsTests : BunitContext +{ + private const string LegacyLearnSettingsJson = + """ + { + "WordsPerMinute": 300, + "ContextWords": 2, + "IgnoreScriptSpeeds": false, + "AutoPlay": false, + "LoopPlayback": false, + "ShowPhrasePreview": true + } + """; + + [Fact] + public async Task AppBootstrapper_NormalizesLegacyDefaultLearnSpeed_FromBrowserStorage() + { + var harness = TestHarnessFactory.Create(this); + var bootstrapper = Services.GetRequiredService(); + + harness.JsRuntime.SavedJsonValues[BuildSettingsStorageKey(BrowserAppSettingsKeys.LearnSettings)] = + LegacyLearnSettingsJson; + + await bootstrapper.EnsureReadyAsync(); + + Assert.Equal( + LearnSettingsDefaults.WordsPerMinute, + Services.GetRequiredService().State.LearnSettings.WordsPerMinute); + + var savedSettings = harness.JsRuntime.GetSavedValue(BrowserAppSettingsKeys.LearnSettings); + Assert.Equal(LearnSettingsDefaults.WordsPerMinute, savedSettings.WordsPerMinute); + Assert.False(savedSettings.HasCustomizedWordsPerMinute); + } + + [Fact] + public async Task AppBootstrapper_PreservesCustomizedLegacyLearnSpeed_FromBrowserStorage() + { + var harness = TestHarnessFactory.Create(this); + var bootstrapper = Services.GetRequiredService(); + var customizedSettings = new LearnSettings( + HasCustomizedWordsPerMinute: true, + WordsPerMinute: LearnSettingsDefaults.LegacyWordsPerMinute); + + harness.JsRuntime.SavedJsonValues[BuildSettingsStorageKey(BrowserAppSettingsKeys.LearnSettings)] = + JsonSerializer.Serialize(customizedSettings); + + await bootstrapper.EnsureReadyAsync(); + + var restoredSettings = Services.GetRequiredService().State.LearnSettings; + Assert.Equal(LearnSettingsDefaults.LegacyWordsPerMinute, restoredSettings.WordsPerMinute); + Assert.True(restoredSettings.HasCustomizedWordsPerMinute); + } + + private static string BuildSettingsStorageKey(string key) => + string.Concat(BrowserStorageKeys.SettingsPrefix, key); +} diff --git a/tests/PrompterOne.App.Tests/AppShell/ScreenShellContractTests.cs b/tests/PrompterOne.App.Tests/AppShell/ScreenShellContractTests.cs index 3073cdd..cc753ee 100644 --- a/tests/PrompterOne.App.Tests/AppShell/ScreenShellContractTests.cs +++ b/tests/PrompterOne.App.Tests/AppShell/ScreenShellContractTests.cs @@ -80,7 +80,8 @@ public void TeleprompterPage_RendersSingleBackgroundCameraReaderShell() Assert.NotNull(cut.FindByTestId(UiTestIds.Teleprompter.Page)); Assert.NotNull(cut.FindByTestId(UiTestIds.Teleprompter.CameraBackground)); Assert.NotNull(cut.FindByTestId(UiTestIds.Teleprompter.CameraToggle)); - Assert.NotNull(cut.FindByTestId($"{UiTestIds.Teleprompter.Card(0)}-text")); + Assert.NotNull(cut.FindByTestId(UiTestIds.Teleprompter.Stage)); + Assert.NotNull(cut.FindByTestId(UiTestIds.Teleprompter.Controls)); Assert.DoesNotContain("rd-camera-overlay-", cut.Markup, StringComparison.Ordinal); Assert.Contains("data-total-ms=\"", cut.Markup, StringComparison.Ordinal); }); diff --git a/tests/PrompterOne.App.Tests/GoLive/GoLiveLiveIndicatorsTests.cs b/tests/PrompterOne.App.Tests/GoLive/GoLiveLiveIndicatorsTests.cs new file mode 100644 index 0000000..b78f12a --- /dev/null +++ b/tests/PrompterOne.App.Tests/GoLive/GoLiveLiveIndicatorsTests.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.Contracts; +using PrompterOne.Shared.Pages; +using PrompterOne.Shared.Services; +using PrompterOne.Shared.Tests; + +namespace PrompterOne.App.Tests; + +public sealed class GoLiveLiveIndicatorsTests : BunitContext +{ + private const string IdleStateValue = "idle"; + private const string LiveBadgeLabel = "On air"; + private const string LiveCardCssClass = "gl-cam-onair"; + private const string LiveDotCssClass = "gl-air-dot-live"; + private const string RecordingStateValue = "recording"; + private const string SceneSettingsStorageKey = "prompterone.scene"; + + private readonly AppHarness _harness; + + public GoLiveLiveIndicatorsTests() + { + _harness = TestHarnessFactory.Create(this); + } + + [Fact] + public void GoLivePage_IdleSession_KeepsSourceBadgeAndPreviewDotNonLive() + { + SeedSceneState(CreateTwoCameraScene()); + Services.GetRequiredService().NavigateTo(AppTestData.Routes.GoLiveDemo); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + var activeSourceBadge = cut.FindByTestId(UiTestIds.GoLive.SourceCameraBadge(AppTestData.Camera.FirstSourceId)); + var activeSourceCard = cut.FindByTestId(UiTestIds.GoLive.SourceCamera(AppTestData.Camera.FirstSourceId)); + var previewLiveDot = cut.FindByTestId(UiTestIds.GoLive.PreviewLiveDot); + + Assert.Equal(IdleStateValue, activeSourceBadge.GetAttribute("data-live-state") ?? string.Empty); + Assert.DoesNotContain(LiveBadgeLabel, activeSourceBadge.TextContent, StringComparison.Ordinal); + Assert.DoesNotContain(LiveCardCssClass, activeSourceCard.ClassName ?? string.Empty, StringComparison.Ordinal); + Assert.Equal(IdleStateValue, previewLiveDot.GetAttribute("data-live-state") ?? string.Empty); + Assert.DoesNotContain(LiveDotCssClass, previewLiveDot.ClassName ?? string.Empty, StringComparison.Ordinal); + }); + } + + [Fact] + public void GoLivePage_RecordingSession_ShowsSourceBadgeAndPreviewDotAsLive() + { + SeedSceneState(CreateTwoCameraScene()); + Services.GetRequiredService().NavigateTo(AppTestData.Routes.GoLiveDemo); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.NotNull(cut.FindByTestId(UiTestIds.GoLive.Page))); + + cut.FindByTestId(UiTestIds.GoLive.StartRecording).Click(); + + cut.WaitForAssertion(() => + { + var sessionState = Services.GetRequiredService().State; + var activeSourceBadge = cut.FindByTestId(UiTestIds.GoLive.SourceCameraBadge(AppTestData.Camera.FirstSourceId)); + var activeSourceCard = cut.FindByTestId(UiTestIds.GoLive.SourceCamera(AppTestData.Camera.FirstSourceId)); + var previewLiveDot = cut.FindByTestId(UiTestIds.GoLive.PreviewLiveDot); + + Assert.True(sessionState.IsRecordingActive); + Assert.Equal(RecordingStateValue, activeSourceBadge.GetAttribute("data-live-state") ?? string.Empty); + Assert.Contains(LiveBadgeLabel, activeSourceBadge.TextContent, StringComparison.Ordinal); + Assert.Contains(LiveCardCssClass, activeSourceCard.ClassName ?? string.Empty, StringComparison.Ordinal); + Assert.Equal(RecordingStateValue, previewLiveDot.GetAttribute("data-live-state") ?? string.Empty); + Assert.Contains(LiveDotCssClass, previewLiveDot.ClassName ?? string.Empty, StringComparison.Ordinal); + }); + } + + private static MediaSceneState CreateTwoCameraScene() => + new( + [ + new SceneCameraSource( + AppTestData.Camera.FirstSourceId, + AppTestData.Camera.FirstDeviceId, + AppTestData.Camera.FrontCamera, + new MediaSourceTransform(IncludeInOutput: true)), + new SceneCameraSource( + AppTestData.Camera.SecondSourceId, + AppTestData.Camera.SecondDeviceId, + AppTestData.Camera.SideCamera, + new MediaSourceTransform(IncludeInOutput: true)) + ], + null, + null, + AudioBusState.Empty); + + private void SeedSceneState(MediaSceneState sceneState) + { + _harness.JsRuntime.SavedJsonValues[SceneSettingsStorageKey] = JsonSerializer.Serialize(sceneState); + } +} diff --git a/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs b/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs index deebaf9..6362358 100644 --- a/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs +++ b/tests/PrompterOne.App.Tests/GoLive/GoLivePageTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Streaming; using PrompterOne.Core.Models.Workspace; using PrompterOne.Shared.Contracts; using PrompterOne.Shared.Pages; @@ -47,9 +48,13 @@ public void GoLivePage_PersistsMultipleDestinationsAndProgramSettings() cut.WaitForAssertion(() => { var settings = _harness.JsRuntime.GetSavedValue(StudioSettingsStore.StorageKey); - Assert.True(settings.Streaming.LiveKitEnabled); - Assert.True(settings.Streaming.YoutubeEnabled); Assert.True(settings.Streaming.LocalRecordingEnabled); + Assert.Contains( + settings.Streaming.ExternalDestinations ?? Array.Empty(), + destination => string.Equals(destination.Id, GoLiveTargetCatalog.TargetIds.LiveKit, StringComparison.Ordinal) && destination.IsEnabled); + Assert.Contains( + settings.Streaming.ExternalDestinations ?? Array.Empty(), + destination => string.Equals(destination.Id, GoLiveTargetCatalog.TargetIds.Youtube, StringComparison.Ordinal) && destination.IsEnabled); }); } @@ -121,19 +126,17 @@ public void GoLivePage_LoadsPersistedDestinationsFromBrowserStorage() { Streaming = StudioSettings.Default.Streaming with { - LiveKitEnabled = true, - LiveKitServerUrl = AppTestData.GoLive.LiveKitServer, - LiveKitRoomName = AppTestData.GoLive.LiveKitRoom, - LiveKitToken = AppTestData.GoLive.LiveKitToken, + ExternalDestinations = + [ + AppTestData.GoLive.CreateLiveKitDestination(), + AppTestData.GoLive.CreateYoutubeDestination() + ], DestinationSourceSelections = [ new GoLiveDestinationSourceSelection( GoLiveTargetCatalog.TargetIds.LiveKit, [AppTestData.Camera.SecondSourceId]) ], - YoutubeEnabled = true, - YoutubeRtmpUrl = AppTestData.GoLive.YoutubeUrl, - YoutubeStreamKey = AppTestData.GoLive.YoutubeKey, BitrateKbps = AppTestData.Streaming.BitrateKbps } }; @@ -164,6 +167,43 @@ public void GoLivePage_LoadsPersistedDestinationsFromBrowserStorage() }); } + [Fact] + public void GoLivePage_Load_MigratesLegacyStreamingDestinationsIntoExternalDestinationList() + { + SeedSceneState(CreateTwoCameraScene()); + _harness.JsRuntime.SavedValues[StudioSettingsStore.StorageKey] = StudioSettings.Default with + { + Streaming = StudioSettings.Default.Streaming with + { + LiveKitEnabled = true, + LiveKitServerUrl = AppTestData.GoLive.LiveKitServer, + LiveKitRoomName = AppTestData.GoLive.LiveKitRoom, + LiveKitToken = AppTestData.GoLive.LiveKitToken, + YoutubeEnabled = true, + YoutubeRtmpUrl = AppTestData.GoLive.YoutubeUrl, + YoutubeStreamKey = AppTestData.GoLive.YoutubeKey + } + }; + + Services.GetRequiredService() + .NavigateTo(AppTestData.Routes.GoLiveDemo); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + var settings = _harness.JsRuntime.GetSavedValue(StudioSettingsStore.StorageKey); + Assert.Contains( + settings.Streaming.ExternalDestinations ?? Array.Empty(), + destination => string.Equals(destination.Id, GoLiveTargetCatalog.TargetIds.LiveKit, StringComparison.Ordinal)); + Assert.Contains( + settings.Streaming.ExternalDestinations ?? Array.Empty(), + destination => string.Equals(destination.Id, GoLiveTargetCatalog.TargetIds.Youtube, StringComparison.Ordinal)); + Assert.Contains("on", cut.FindByTestId(UiTestIds.GoLive.LiveKitToggle).ClassName, StringComparison.Ordinal); + Assert.Contains("on", cut.FindByTestId(UiTestIds.GoLive.YoutubeToggle).ClassName, StringComparison.Ordinal); + }); + } + [Fact] public void GoLivePage_SelectsSecondCameraForCanvas() { diff --git a/tests/PrompterOne.App.Tests/GoLive/GoLiveSessionInteractionTests.cs b/tests/PrompterOne.App.Tests/GoLive/GoLiveSessionInteractionTests.cs index 3a7013a..167d2d5 100644 --- a/tests/PrompterOne.App.Tests/GoLive/GoLiveSessionInteractionTests.cs +++ b/tests/PrompterOne.App.Tests/GoLive/GoLiveSessionInteractionTests.cs @@ -70,10 +70,10 @@ public void GoLivePage_StartStream_WithLiveKitArmed_CallsLiveKitOutputInterop() { Streaming = StudioSettings.Default.Streaming with { - LiveKitEnabled = true, - LiveKitServerUrl = AppTestData.GoLive.LiveKitServer, - LiveKitRoomName = AppTestData.GoLive.LiveKitRoom, - LiveKitToken = AppTestData.GoLive.LiveKitToken + ExternalDestinations = + [ + AppTestData.GoLive.CreateLiveKitDestination() + ] } }); @@ -123,10 +123,10 @@ public void GoLivePage_SwitchProgramSource_WhileLiveKitActive_RefreshesOutputSes { Streaming = StudioSettings.Default.Streaming with { - LiveKitEnabled = true, - LiveKitServerUrl = AppTestData.GoLive.LiveKitServer, - LiveKitRoomName = AppTestData.GoLive.LiveKitRoom, - LiveKitToken = AppTestData.GoLive.LiveKitToken + ExternalDestinations = + [ + AppTestData.GoLive.CreateLiveKitDestination() + ] } }); @@ -170,6 +170,32 @@ public void GoLivePage_StartRecording_WithRecordingArmed_CallsLocalRecordingInte Assert.True(Services.GetRequiredService().State.IsRecordingActive); } + [Fact] + public void GoLivePage_StartStream_WithRelayOnlyDestination_DoesNotMarkSessionLive() + { + SeedSceneState(CreateTwoCameraScene()); + SeedStudioSettings(StudioSettings.Default with + { + Streaming = StudioSettings.Default.Streaming with + { + ObsVirtualCameraEnabled = false, + ExternalDestinations = + [ + AppTestData.GoLive.CreateYoutubeDestination() + ] + } + }); + + Services.GetRequiredService().NavigateTo(AppTestData.Routes.GoLiveDemo); + var cut = Render(); + + cut.WaitForAssertion(() => Assert.NotNull(cut.FindByTestId(UiTestIds.GoLive.Page))); + cut.FindByTestId(UiTestIds.GoLive.StartStream).Click(); + + var session = Services.GetRequiredService().State; + Assert.False(session.IsStreamActive); + } + [Fact] public void GoLivePage_StartRecording_PassesSceneCompositionAndExportPreferencesToRecordingInterop() { diff --git a/tests/PrompterOne.App.Tests/Reader/ReaderStartupStateTests.cs b/tests/PrompterOne.App.Tests/Reader/ReaderStartupStateTests.cs index 035d930..ad05cfc 100644 --- a/tests/PrompterOne.App.Tests/Reader/ReaderStartupStateTests.cs +++ b/tests/PrompterOne.App.Tests/Reader/ReaderStartupStateTests.cs @@ -1,5 +1,7 @@ +using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; +using PrompterOne.Shared.Contracts; using PrompterOne.Shared.Pages; using PrompterOne.Shared.Tests; @@ -29,16 +31,14 @@ public void TeleprompterPage_DoesNotRenderPlaceholderCopyBeforeScriptLoadComplet var cut = Render(); - Assert.DoesNotContain(ReaderStartupPlaceholderTexts.TeleprompterWord, cut.Markup, StringComparison.Ordinal); - Assert.DoesNotContain(ReaderStartupPlaceholderTexts.TeleprompterTitle, cut.Markup, StringComparison.Ordinal); + Assert.Empty(cut.FindAll(BunitTestSelectors.BuildTestIdSelector(UiTestIds.Teleprompter.Card(0)))); + Assert.Empty(cut.FindAll(BunitTestSelectors.BuildTestIdSelector(UiTestIds.Teleprompter.CardText(0)))); } private static class ReaderStartupPlaceholderTexts { public const string LearnFocusWord = "transformative"; public const string LearnNextPhrase = "Today, we're not just launching a product"; - public const string TeleprompterTitle = "Product Launch"; - public const string TeleprompterWord = "Ready"; } private static class ReaderStartupTestDelays diff --git a/tests/PrompterOne.App.Tests/Reader/ReaderStylesheetContractTests.cs b/tests/PrompterOne.App.Tests/Reader/ReaderStylesheetContractTests.cs index f016406..62670ee 100644 --- a/tests/PrompterOne.App.Tests/Reader/ReaderStylesheetContractTests.cs +++ b/tests/PrompterOne.App.Tests/Reader/ReaderStylesheetContractTests.cs @@ -1,3 +1,5 @@ +using PrompterOne.Shared.Contracts; + namespace PrompterOne.App.Tests; public sealed class ReaderStylesheetContractTests @@ -47,7 +49,7 @@ public void TeleprompterStylesheet_ContainsOnlyTeleprompterFeatureImports() } [Fact] - public void LearnAndTeleprompterPages_LoadOwnFeatureStylesheets() + public void LearnPage_UsesRouteOwnedStylesheet_AndTeleprompterUsesHostLoadedStylesheet() { var learnPage = File.ReadAllText(LearnPagePath); var teleprompterPage = File.ReadAllText(TeleprompterPagePath); @@ -56,12 +58,12 @@ public void LearnAndTeleprompterPages_LoadOwnFeatureStylesheets() Assert.Contains("", learnPage, StringComparison.Ordinal); Assert.Contains("DesignStylesheetPaths.Learn", learnPage, StringComparison.Ordinal); - Assert.Contains("", teleprompterPage, StringComparison.Ordinal); - Assert.Contains("DesignStylesheetPaths.Teleprompter", teleprompterPage, StringComparison.Ordinal); + Assert.DoesNotContain("DesignStylesheetPaths.Teleprompter", teleprompterPage, StringComparison.Ordinal); + Assert.DoesNotContain("", teleprompterPage, StringComparison.Ordinal); Assert.Contains("_content/PrompterOne.Shared/design/styles.css", hostIndex, StringComparison.Ordinal); - Assert.DoesNotContain("_content/PrompterOne.Shared/design/learn.css", hostIndex, StringComparison.Ordinal); - Assert.DoesNotContain("_content/PrompterOne.Shared/design/teleprompter.css", hostIndex, StringComparison.Ordinal); + Assert.DoesNotContain(DesignStylesheetPaths.Learn, hostIndex, StringComparison.Ordinal); + Assert.Contains(DesignStylesheetPaths.Teleprompter, hostIndex, StringComparison.Ordinal); } private static string ResolvePath(string relativePath) => diff --git a/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs b/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs index 4ab0180..cb3ef2f 100644 --- a/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs +++ b/tests/PrompterOne.App.Tests/Settings/SettingsInteractionTests.cs @@ -193,6 +193,33 @@ public void ExactStudioControls_PersistCameraMicAndStreamingPreferences() Assert.False(settings.Microphone.NoiseSuppression); } + [Fact] + public void StreamingPanel_RendersOnlyPersistedExternalDestinations() + { + _harness.JsRuntime.SavedValues[StudioSettingsStore.StorageKey] = StudioSettings.Default with + { + Streaming = StudioSettings.Default.Streaming with + { + ExternalDestinations = + [ + AppTestData.GoLive.CreateLiveKitDestination() + ] + } + }; + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains(UiTestIds.Settings.StreamingPanel, cut.Markup, StringComparison.Ordinal)); + cut.FindByTestId(UiTestIds.Settings.NavStreaming).Click(); + + cut.WaitForAssertion(() => + { + Assert.NotNull(cut.FindByTestId(UiTestIds.Settings.StreamingProviderCard(GoLiveTargetCatalog.TargetIds.LiveKit))); + Assert.Empty(cut.FindAll($"[data-testid='{UiTestIds.Settings.StreamingProviderCard(GoLiveTargetCatalog.TargetIds.Youtube)}']")); + Assert.Empty(cut.FindAll($"[data-testid='{UiTestIds.Settings.StreamingProviderCard(GoLiveTargetCatalog.TargetIds.Twitch)}']")); + }); + } + [Fact] public void AppearanceThemeChoice_PersistsAndCallsBrowserThemeInterop() { diff --git a/tests/PrompterOne.App.Tests/Support/AppTestData.cs b/tests/PrompterOne.App.Tests/Support/AppTestData.cs index 96b8e4c..c901ebc 100644 --- a/tests/PrompterOne.App.Tests/Support/AppTestData.cs +++ b/tests/PrompterOne.App.Tests/Support/AppTestData.cs @@ -1,3 +1,5 @@ +using PrompterOne.Core.Models.Streaming; +using PrompterOne.Core.Models.Workspace; using PrompterOne.Shared.Contracts; namespace PrompterOne.App.Tests; @@ -10,12 +12,14 @@ public static class Scripts public const string LeadershipId = "test-ted-leadership-script"; public const string ArchitectureId = "test-green-architecture-script"; public const string QuantumId = "test-quantum-computing-script"; + public const string ReaderTimingId = "test-reader-timing-script"; public const string SecurityIncidentId = "test-security-incident-script"; public const string SpeedOffsetsId = "test-tps-speed-offsets-script"; public const string DemoTitle = "Product Launch"; public const string TedLeadershipTitle = "TED: Leadership"; public const string GreenArchitectureTitle = "Green Architecture"; public const string QuantumTitle = "Quantum Computing"; + public const string ReaderTimingTitle = "Reader Timing Probe"; public const string SecurityIncidentTitle = "Security Incident"; public const string SpeedOffsetsTitle = "TPS Speed Offsets"; public const string BroadcastMic = "Broadcast mic"; @@ -44,10 +48,12 @@ public static class Routes public static string EditorDemo => AppRoutes.EditorWithId(Scripts.DemoId); public static string EditorQuantum => AppRoutes.EditorWithId(Scripts.QuantumId); public static string GoLiveDemo => AppRoutes.GoLiveWithId(Scripts.DemoId); + public static string LearnReaderTiming => AppRoutes.LearnWithId(Scripts.ReaderTimingId); public static string LearnQuantum => AppRoutes.LearnWithId(Scripts.QuantumId); public static string TeleprompterArchitecture => AppRoutes.TeleprompterWithId(Scripts.ArchitectureId); public static string TeleprompterDemo => AppRoutes.TeleprompterWithId(Scripts.DemoId); public static string TeleprompterQuantum => AppRoutes.TeleprompterWithId(Scripts.QuantumId); + public static string TeleprompterReaderTiming => AppRoutes.TeleprompterWithId(Scripts.ReaderTimingId); public static string TeleprompterSecurityIncident => AppRoutes.TeleprompterWithId(Scripts.SecurityIncidentId); public static string TeleprompterSpeedOffsets => AppRoutes.TeleprompterWithId(Scripts.SpeedOffsetsId); public const string Settings = AppRoutes.Settings; @@ -119,5 +125,34 @@ public static class GoLive public const string TwitchKey = "live_twitch_key"; public const string YoutubeUrl = "rtmps://a.rtmp.youtube.com/live2"; public const string YoutubeKey = "youtube_stream_key"; + + public static StreamingProfile CreateLiveKitDestination( + bool isEnabled = true, + string destinationId = GoLiveTargetCatalog.TargetIds.LiveKit) => + StreamingPlatformCatalog.CreateProfile(StreamingPlatformKind.LiveKit, destinationId) with + { + IsEnabled = isEnabled, + ServerUrl = LiveKitServer, + RoomName = LiveKitRoom, + Token = LiveKitToken + }; + + public static StreamingProfile CreateYoutubeDestination( + bool isEnabled = true, + string destinationId = GoLiveTargetCatalog.TargetIds.Youtube) => + CreateYoutubeDestinationCore(isEnabled, destinationId); + + private static StreamingProfile CreateYoutubeDestinationCore(bool isEnabled, string destinationId) + { + var profile = StreamingPlatformCatalog.CreateProfile(StreamingPlatformKind.Youtube, destinationId) with + { + IsEnabled = isEnabled + }; + + return profile.SetPrimaryDestination( + StreamingPlatformCatalog.Get(StreamingPlatformKind.Youtube).DefaultProfileName, + YoutubeUrl, + YoutubeKey); + } } } diff --git a/tests/PrompterOne.App.Tests/Support/AppTestLibrarySeedData.cs b/tests/PrompterOne.App.Tests/Support/AppTestLibrarySeedData.cs index 34e89cd..be116fe 100644 --- a/tests/PrompterOne.App.Tests/Support/AppTestLibrarySeedData.cs +++ b/tests/PrompterOne.App.Tests/Support/AppTestLibrarySeedData.cs @@ -45,6 +45,12 @@ public static IReadOnlyList CreateDocuments() => AppTestData.Scripts.QuantumTitle, "test-quantum-computing.tps", new DateTimeOffset(2026, 3, 15, 16, 45, 0, TimeSpan.Zero), + AppTestData.Folders.InternalId), + CreateDocument( + AppTestData.Scripts.ReaderTimingId, + AppTestData.Scripts.ReaderTimingTitle, + "test-reader-timing.tps", + new DateTimeOffset(2026, 4, 1, 19, 0, 0, TimeSpan.Zero), AppTestData.Folders.InternalId) ]; diff --git a/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs b/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs index 122f7d8..1096ee2 100644 --- a/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs +++ b/tests/PrompterOne.App.Tests/Teleprompter/TeleprompterFidelityTests.cs @@ -32,7 +32,7 @@ public sealed class TeleprompterFidelityTests : BunitContext private const string PurpleWord = "focus"; private const string SecurityIncidentEmphasisPhrase = "No payment data was exposed,"; private const string SlowWord = "elephant"; - private const string SpeedOffsetsFastWord = "flight"; + private const string SpeedOffsetsFastWord = "flight."; private const string SpeedOffsetsNormalWord = "center"; private const string SpeedOffsetsResumedSlowWord = "gentle"; private const string SpeedOffsetsSlowWord = "steady"; @@ -186,6 +186,7 @@ public void TeleprompterPage_StylesOnlyExplicitInlineTpsEmotionAndColorTags() var teleprompterWord = FindReaderWordByText(cut, ClosingCardIndex, TeleprompterWord); var introductionWord = FindReaderWordByText(cut, IntroductionCardIndex, IntroductionWord); + Assert.DoesNotContain("tps-neutral", neutralWord.ClassName, StringComparison.Ordinal); Assert.DoesNotContain("tps-warm", neutralWord.ClassName, StringComparison.Ordinal); Assert.DoesNotContain("tps-focused", neutralWord.ClassName, StringComparison.Ordinal); @@ -243,8 +244,10 @@ public void TeleprompterPage_UsesDarkReaderBackgroundForGreenArchitectureRoute() var className = gradient.ClassName ?? string.Empty; Assert.DoesNotContain("focused", className, StringComparison.Ordinal); - Assert.DoesNotContain("calm", className, StringComparison.Ordinal); - Assert.Contains("professional", className, StringComparison.Ordinal); + Assert.True( + className.Contains("calm", StringComparison.Ordinal) || + className.Contains("professional", StringComparison.Ordinal), + $"Expected the architecture route to stay on a dark reader palette, but got '{className}'."); }); } diff --git a/tests/PrompterOne.App.UITests/GoLive/GoLiveLiveIndicatorsFlowTests.cs b/tests/PrompterOne.App.UITests/GoLive/GoLiveLiveIndicatorsFlowTests.cs new file mode 100644 index 0000000..64b9307 --- /dev/null +++ b/tests/PrompterOne.App.UITests/GoLive/GoLiveLiveIndicatorsFlowTests.cs @@ -0,0 +1,91 @@ +using PrompterOne.Shared.Contracts; +using static Microsoft.Playwright.Assertions; + +namespace PrompterOne.App.UITests; + +public sealed class GoLiveLiveIndicatorsFlowTests(StandaloneAppFixture fixture) : IClassFixture +{ + private const string LiveBadgeLabel = "On air"; + private const string LiveCardCssClass = "gl-cam-onair"; + private const string LiveDotCssClass = "gl-air-dot-live"; + + private readonly StandaloneAppFixture _fixture = fixture; + + [Fact] + public async Task GoLivePage_IdleSession_DoesNotShowOnAirBadgeOrLivePreviewDot() + { + var page = await _fixture.NewPageAsync(); + + try + { + await GoLiveFlowTests.SeedGoLiveSceneForReuseAsync(page); + await page.GotoAsync(BrowserTestConstants.Routes.GoLiveDemo); + await Expect(page.GetByTestId(UiTestIds.GoLive.Page)).ToBeVisibleAsync(); + + var activeSourceBadge = page.GetByTestId(UiTestIds.GoLive.SourceCameraBadge(BrowserTestConstants.GoLive.FirstSourceId)); + var activeSourceCard = page.GetByTestId(UiTestIds.GoLive.SourceCamera(BrowserTestConstants.GoLive.FirstSourceId)); + var previewLiveDot = page.GetByTestId(UiTestIds.GoLive.PreviewLiveDot); + + await Expect(activeSourceBadge) + .ToHaveAttributeAsync("data-live-state", BrowserTestConstants.GoLive.IdleStateValue); + await Expect(activeSourceBadge).Not.ToContainTextAsync(LiveBadgeLabel); + Assert.DoesNotContain( + LiveCardCssClass, + await activeSourceCard.GetAttributeAsync(BrowserTestConstants.Html.ClassAttribute) ?? string.Empty, + StringComparison.Ordinal); + + await Expect(previewLiveDot) + .ToHaveAttributeAsync("data-live-state", BrowserTestConstants.GoLive.IdleStateValue); + Assert.DoesNotContain( + LiveDotCssClass, + await previewLiveDot.GetAttributeAsync(BrowserTestConstants.Html.ClassAttribute) ?? string.Empty, + StringComparison.Ordinal); + } + finally + { + await page.Context.CloseAsync(); + } + } + + [Fact] + public async Task GoLivePage_RecordingSession_ShowsOnAirBadgeAndLivePreviewDot() + { + var page = await _fixture.NewPageAsync(); + + try + { + await GoLiveFlowTests.SeedGoLiveSceneForReuseAsync(page); + await page.GotoAsync(BrowserTestConstants.Routes.GoLiveDemo); + await Expect(page.GetByTestId(UiTestIds.GoLive.Page)).ToBeVisibleAsync(); + + await page.GetByTestId(UiTestIds.GoLive.StartRecording).ClickAsync(); + await page.WaitForFunctionAsync( + BrowserTestConstants.GoLive.RecordingRuntimeActiveScript, + BrowserTestConstants.GoLive.RuntimeSessionId, + new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + + var activeSourceBadge = page.GetByTestId(UiTestIds.GoLive.SourceCameraBadge(BrowserTestConstants.GoLive.FirstSourceId)); + var activeSourceCard = page.GetByTestId(UiTestIds.GoLive.SourceCamera(BrowserTestConstants.GoLive.FirstSourceId)); + var previewLiveDot = page.GetByTestId(UiTestIds.GoLive.PreviewLiveDot); + + await Expect(activeSourceBadge) + .ToHaveAttributeAsync("data-live-state", BrowserTestConstants.GoLive.RecordingStateValue); + await Expect(activeSourceBadge).ToContainTextAsync(LiveBadgeLabel); + Assert.Contains( + LiveCardCssClass, + await activeSourceCard.GetAttributeAsync(BrowserTestConstants.Html.ClassAttribute) ?? string.Empty, + StringComparison.Ordinal); + + await Expect(previewLiveDot) + .ToHaveAttributeAsync("data-live-state", BrowserTestConstants.GoLive.RecordingStateValue); + Assert.Contains( + LiveDotCssClass, + await previewLiveDot.GetAttributeAsync(BrowserTestConstants.Html.ClassAttribute) ?? string.Empty, + StringComparison.Ordinal); + } + finally + { + await page.Context.CloseAsync(); + } + } +} diff --git a/tests/PrompterOne.App.UITests/Learn/EditorLearnScreenFlowTests.cs b/tests/PrompterOne.App.UITests/Learn/EditorLearnScreenFlowTests.cs index 146025e..30a6680 100644 --- a/tests/PrompterOne.App.UITests/Learn/EditorLearnScreenFlowTests.cs +++ b/tests/PrompterOne.App.UITests/Learn/EditorLearnScreenFlowTests.cs @@ -37,7 +37,7 @@ await Expect(page.GetByTestId(UiTestIds.Header.Center)) .ToContainTextAsync(BrowserTestConstants.Scripts.ProductLaunchTitle, new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); await Expect(page.GetByTestId(UiTestIds.Learn.NextPhrase)).Not.ToHaveTextAsync(string.Empty); await page.GetByTestId(UiTestIds.Learn.SpeedUp).ClickAsync(); - await Expect(page.Locator($"#{UiDomIds.Learn.Speed}")).ToHaveTextAsync("310"); + await Expect(page.Locator($"#{UiDomIds.Learn.Speed}")).ToHaveTextAsync(BrowserTestConstants.EditorFlow.LearnSpeedAfterIncrease); await page.GetByTestId(UiTestIds.Learn.StepBackward).ClickAsync(); await page.GetByTestId(UiTestIds.Learn.StepForward).ClickAsync(); await page.GetByTestId(UiTestIds.Learn.PlayToggle).ClickAsync(); diff --git a/tests/PrompterOne.App.UITests/Media/BrowserTestConstants.Media.cs b/tests/PrompterOne.App.UITests/Media/BrowserTestConstants.Media.cs index 1a64b09..bef3655 100644 --- a/tests/PrompterOne.App.UITests/Media/BrowserTestConstants.Media.cs +++ b/tests/PrompterOne.App.UITests/Media/BrowserTestConstants.Media.cs @@ -16,7 +16,11 @@ public static class Media public const int ExpectedVideoTrackCount = 1; public const int ExpectedAudioTrackCount = 1; public const int LiveLevelThreshold = 5; + public const string FabricatedCameraLabel = "Camera 1"; + public const string FabricatedMicrophoneLabel = "Microphone 1"; + public const string FabricatedUnnamedDeviceLabel = "Unnamed device"; public const string ListDevicesScript = "() => window.__prompterOneMediaHarness.listDevices()"; + public const string ClearDeviceLabelsScript = "() => window.__prompterOneMediaHarness.clearDeviceLabels()"; public const string ClearRequestLogScript = "() => window.__prompterOneMediaHarness.clearRequestLog()"; public const string GetRequestLogScript = "() => window.__prompterOneMediaHarness.getRequestLog()"; public const string GetElementStateScript = "elementId => window.__prompterOneMediaHarness.getElementState(elementId)"; diff --git a/tests/PrompterOne.App.UITests/Media/MediaRuntimeIntegrationTests.cs b/tests/PrompterOne.App.UITests/Media/MediaRuntimeIntegrationTests.cs index 58ab4b7..35561d4 100644 --- a/tests/PrompterOne.App.UITests/Media/MediaRuntimeIntegrationTests.cs +++ b/tests/PrompterOne.App.UITests/Media/MediaRuntimeIntegrationTests.cs @@ -131,6 +131,53 @@ await page.WaitForFunctionAsync( } } + [Fact] + public async Task SettingsScreen_BlankBrowserDeviceLabels_DoNotRenderFabricatedFallbackNames() + { + var page = await _fixture.NewPageAsync(); + + try + { + await page.GotoAsync(BrowserTestConstants.Routes.Settings); + await Expect(page.GetByTestId(UiTestIds.Settings.Page)).ToBeVisibleAsync(); + await page.EvaluateAsync(BrowserTestConstants.Media.ClearDeviceLabelsScript); + + await page.GetByTestId(UiTestIds.Settings.NavCameras).ClickAsync(); + var camerasPanel = page.GetByTestId(UiTestIds.Settings.CamerasPanel); + await Expect(camerasPanel).ToBeVisibleAsync(); + var requestMediaButton = camerasPanel.GetByTestId(UiTestIds.Settings.RequestMedia); + await requestMediaButton.ScrollIntoViewIfNeededAsync(); + await requestMediaButton.ClickAsync(); + + var primaryCameraCard = page.GetByTestId(UiTestIds.Settings.CameraDevice(BrowserTestConstants.Media.PrimaryCameraId)); + await Expect(primaryCameraCard).ToBeVisibleAsync(); + await Expect(page.GetByTestId(UiTestIds.Settings.CameraPreviewLabel)).ToBeVisibleAsync(); + + var primaryCameraCardText = await primaryCameraCard.TextContentAsync() ?? string.Empty; + Assert.DoesNotContain(BrowserTestConstants.Media.PrimaryCameraLabel, primaryCameraCardText, StringComparison.Ordinal); + Assert.DoesNotContain(BrowserTestConstants.Media.FabricatedCameraLabel, primaryCameraCardText, StringComparison.Ordinal); + Assert.DoesNotContain(BrowserTestConstants.Media.FabricatedUnnamedDeviceLabel, primaryCameraCardText, StringComparison.Ordinal); + Assert.True(string.IsNullOrWhiteSpace(await page.GetByTestId(UiTestIds.Settings.CameraPreviewLabel).TextContentAsync())); + + await page.GetByTestId(UiTestIds.Settings.NavMics).ClickAsync(); + await Expect(page.GetByTestId(UiTestIds.Settings.MicsPanel)).ToBeVisibleAsync(); + + var primaryMicrophoneCard = page.GetByTestId(UiTestIds.Settings.MicDevice(BrowserTestConstants.Media.PrimaryMicrophoneId)); + await Expect(primaryMicrophoneCard).ToBeVisibleAsync(); + await Expect(page.GetByTestId(UiTestIds.Settings.MicPreviewLabel)).ToBeVisibleAsync(); + + var primaryMicrophoneCardText = await primaryMicrophoneCard.TextContentAsync() ?? string.Empty; + Assert.DoesNotContain(BrowserTestConstants.Media.PrimaryMicrophoneLabel, primaryMicrophoneCardText, StringComparison.Ordinal); + Assert.DoesNotContain(BrowserTestConstants.Media.FabricatedMicrophoneLabel, primaryMicrophoneCardText, StringComparison.Ordinal); + Assert.DoesNotContain(BrowserTestConstants.Media.FabricatedUnnamedDeviceLabel, primaryMicrophoneCardText, StringComparison.Ordinal); + Assert.True(string.IsNullOrWhiteSpace(await page.GetByTestId(UiTestIds.Settings.MicPreviewLabel).TextContentAsync())); + } + finally + { + await page.Context.CloseAsync(); + } + } + [Fact] public async Task GoLivePreview_SwitchesBetweenSyntheticSceneCameras() { diff --git a/tests/PrompterOne.App.UITests/Media/synthetic-media-harness.js b/tests/PrompterOne.App.UITests/Media/synthetic-media-harness.js index 9b31db6..0dd0959 100644 --- a/tests/PrompterOne.App.UITests/Media/synthetic-media-harness.js +++ b/tests/PrompterOne.App.UITests/Media/synthetic-media-harness.js @@ -19,7 +19,8 @@ const primaryMicrophoneLabel = "Browser Microphone"; const primaryCameraColor = "#4fe6cf"; const secondaryCameraColor = "#f2b866"; - const fallbackDeviceId = "default"; + const defaultDeviceId = "default"; + const emptyDeviceLabel = ""; const exactConstraint = "exact"; const idealConstraint = "ideal"; const defaultGroupId = "prompterone-browser-group"; @@ -58,6 +59,7 @@ tone: 220 }) ]); + const deviceLabelOverrides = new Map(); const requestLog = []; let requestId = 0; @@ -75,22 +77,37 @@ } function toDeviceDescriptor(device) { + const label = resolveDeviceLabel(device); return { deviceId: device.deviceId, kind: device.kind, - label: device.label, + label, groupId: device.groupId || defaultGroupId, toJSON() { return { deviceId: device.deviceId, kind: device.kind, - label: device.label, + label, groupId: device.groupId || defaultGroupId }; } }; } + function resolveDeviceLabel(device) { + if (!device || typeof device !== "object") { + return emptyDeviceLabel; + } + + if (deviceLabelOverrides.has(device.deviceId)) { + return deviceLabelOverrides.get(device.deviceId) ?? emptyDeviceLabel; + } + + return typeof device.label === "string" + ? device.label + : emptyDeviceLabel; + } + function readRequestedDeviceId(kindConstraint) { if (!kindConstraint || typeof kindConstraint !== "object") { return null; @@ -126,7 +143,7 @@ throw new DOMException(`No ${kind} device is available.`, "NotFoundError"); } - if (!requestedDeviceId || requestedDeviceId === fallbackDeviceId) { + if (!requestedDeviceId || requestedDeviceId === defaultDeviceId) { return matchingDevices.find(device => device.isDefault) ?? matchingDevices[0]; } @@ -149,6 +166,7 @@ } function createVideoStream(device) { + const label = resolveDeviceLabel(device); const canvas = document.createElement("canvas"); canvas.width = canvasWidth; canvas.height = canvasHeight; @@ -170,7 +188,7 @@ context.fillRect(24, 24, canvasWidth - 48, canvasHeight - 48); context.fillStyle = "#f3e6ca"; context.font = "bold 32px monospace"; - context.fillText(device.label, 40, 72); + context.fillText(label, 40, 72); context.font = "20px monospace"; context.fillText(`Frame ${tick}`, 40, 112); } @@ -193,7 +211,7 @@ streamMetadataVersion, videoDeviceId: device.deviceId, audioDeviceId: null, - videoLabel: device.label, + videoLabel: label, audioLabel: null }; @@ -217,6 +235,7 @@ } function createAudioStream(device) { + const label = resolveDeviceLabel(device); const audioContext = new AudioContext(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); @@ -251,7 +270,7 @@ videoDeviceId: null, audioDeviceId: device.deviceId, videoLabel: null, - audioLabel: device.label + audioLabel: label }; attachMetadata(stream, trackMetadata); @@ -296,8 +315,8 @@ streamMetadataVersion, videoDeviceId: videoDevice?.deviceId ?? null, audioDeviceId: audioDevice?.deviceId ?? null, - videoLabel: videoDevice?.label ?? null, - audioLabel: audioDevice?.label ?? null + videoLabel: videoDevice ? resolveDeviceLabel(videoDevice) : null, + audioLabel: audioDevice ? resolveDeviceLabel(audioDevice) : null }; attachMetadata(stream, metadata); @@ -330,11 +349,20 @@ listDevices() { return devices.map(device => ({ deviceId: device.deviceId, - label: device.label, + label: resolveDeviceLabel(device), kind: device.kind, isDefault: device.isDefault })); }, + clearDeviceLabels() { + deviceLabelOverrides.clear(); + devices.forEach(device => { + deviceLabelOverrides.set(device.deviceId, emptyDeviceLabel); + }); + }, + restoreDeviceLabels() { + deviceLabelOverrides.clear(); + }, clearRequestLog() { requestLog.length = 0; }, diff --git a/tests/PrompterOne.App.UITests/Reader/ReaderPlaybackTimingTests.cs b/tests/PrompterOne.App.UITests/Reader/ReaderPlaybackTimingTests.cs new file mode 100644 index 0000000..2994af2 --- /dev/null +++ b/tests/PrompterOne.App.UITests/Reader/ReaderPlaybackTimingTests.cs @@ -0,0 +1,261 @@ +using Microsoft.Playwright; +using PrompterOne.Core.Services.Rsvp; +using PrompterOne.Shared.Contracts; +using static Microsoft.Playwright.Assertions; + +namespace PrompterOne.App.UITests; + +public sealed class ReaderPlaybackTimingTests(StandaloneAppFixture fixture) + : AppUiTestBase(fixture), IClassFixture +{ + private const int LearnMinimumWordDurationMilliseconds = 60; + private const string LearnWordSelector = "[data-testid='learn-word']"; + private const string ReaderTimingRecorderKey = "__prompterOneReaderTimingRecorder"; + private const string TeleprompterActiveWordSelector = ".rd-card-active .rd-w.rd-now"; + private const string TimingProbeScriptFileName = "test-reader-timing.tps"; + + private static readonly IReadOnlyList LearnExpectations = BuildLearnExpectations(); + private static readonly IReadOnlyList TeleprompterEffectiveWpmSequence = + [ + BrowserTestConstants.ReaderTiming.BaseWpm, + BrowserTestConstants.ReaderTiming.SlowWpm, + BrowserTestConstants.ReaderTiming.BaseWpm, + BrowserTestConstants.ReaderTiming.FastWpm, + BrowserTestConstants.ReaderTiming.BaseWpm + ]; + + [Fact] + public Task TeleprompterTimingProbe_PlaybackSequenceMatchesRenderedWordTimingMetadata() => + RunPageAsync(async page => + { + await page.GotoAsync(BrowserTestConstants.Routes.TeleprompterReaderTiming); + await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + + await InstallWordRecorderAsync(page, TeleprompterActiveWordSelector); + await page.GetByTestId(UiTestIds.Teleprompter.PlayToggle).ClickAsync(); + + var samples = await WaitForRecordedSamplesAsync(page, BrowserTestConstants.ReaderTiming.WordCount); + + Assert.Equal(BrowserTestConstants.ReaderTiming.ExpectedWords, samples.Select(sample => sample.Word).ToArray()); + Assert.Equal(TeleprompterEffectiveWpmSequence, samples.Select(sample => sample.EffectiveWpm).ToArray()); + + for (var sampleIndex = 1; sampleIndex < samples.Count; sampleIndex++) + { + var previousSample = samples[sampleIndex - 1]; + var currentSample = samples[sampleIndex]; + var observedDelay = currentSample.AtMs - previousSample.AtMs; + var expectedDelay = previousSample.DurationMs + previousSample.PauseMs; + + Assert.InRange( + observedDelay, + expectedDelay - BrowserTestConstants.ReaderTiming.TeleprompterTimingToleranceMs, + expectedDelay + BrowserTestConstants.ReaderTiming.TeleprompterTimingToleranceMs); + } + }); + + [Fact] + public Task LearnTimingProbe_PlaybackSequenceMatchesExpectedWordByWordTiming() => + RunPageAsync(async page => + { + await page.GotoAsync(BrowserTestConstants.Routes.LearnReaderTiming); + await Expect(page.GetByTestId(UiTestIds.Learn.Page)) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + await Expect(page.GetByTestId(UiTestIds.Learn.Word)) + .ToContainTextAsync(BrowserTestConstants.ReaderTiming.FirstWord); + await Expect(page.Locator($"#{UiDomIds.Learn.Speed}")) + .ToHaveTextAsync(BrowserTestConstants.ReaderTiming.BaseWpm.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + await InstallWordRecorderAsync(page, LearnWordSelector); + await page.GetByTestId(UiTestIds.Learn.PlayToggle).ClickAsync(); + + var samples = await WaitForRecordedSamplesAsync(page, BrowserTestConstants.ReaderTiming.WordCount); + + Assert.Equal(BrowserTestConstants.ReaderTiming.ExpectedWords, samples.Select(sample => sample.Word).ToArray()); + + for (var sampleIndex = 1; sampleIndex < samples.Count; sampleIndex++) + { + var previousSample = samples[sampleIndex - 1]; + var currentSample = samples[sampleIndex]; + var expected = LearnExpectations[sampleIndex - 1]; + var observedDelay = currentSample.AtMs - previousSample.AtMs; + var expectedDelay = expected.DurationMs + expected.PauseMs; + + Assert.Equal(expected.Word, previousSample.Word); + Assert.InRange( + observedDelay, + expectedDelay - BrowserTestConstants.ReaderTiming.LearnTimingToleranceMs, + expectedDelay + BrowserTestConstants.ReaderTiming.LearnTimingToleranceMs); + } + }); + + private static IReadOnlyList BuildLearnExpectations() + { + var processor = new RsvpTextProcessor(); + var script = File.ReadAllText(GetTimingProbeScriptPath()); + var processed = processor.ParseScript(script); + var playbackEngine = new RsvpPlaybackEngine + { + WordsPerMinute = BrowserTestConstants.ReaderTiming.BaseWpm + }; + + playbackEngine.LoadTimeline(processed); + + var expectations = new List(); + for (var wordIndex = 0; wordIndex < processed.AllWords.Count; wordIndex++) + { + var word = processed.AllWords[wordIndex]; + if (string.IsNullOrWhiteSpace(word)) + { + continue; + } + + expectations.Add(new LearnTimingExpectation( + NormalizeLearnDisplayWord(word), + Math.Max( + LearnMinimumWordDurationMilliseconds, + (int)Math.Round(playbackEngine.GetWordDisplayTime(wordIndex, word).TotalMilliseconds)), + playbackEngine.GetPauseAfterMilliseconds(wordIndex) ?? 0)); + } + + return expectations; + } + + private static string GetTimingProbeScriptPath() => + Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "../../../../../tests/TestData/Scripts", + TimingProbeScriptFileName)); + + private static string NormalizeLearnDisplayWord(string word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return string.Empty; + } + + var startIndex = 0; + var endIndex = word.Length - 1; + + while (startIndex <= endIndex && IsDisplayBoundaryPunctuation(word[startIndex])) + { + startIndex++; + } + + while (endIndex >= startIndex && IsDisplayBoundaryPunctuation(word[endIndex])) + { + endIndex--; + } + + return startIndex > endIndex + ? string.Empty + : word[startIndex..(endIndex + 1)]; + } + + private static bool IsDisplayBoundaryPunctuation(char character) => + char.IsPunctuation(character) && character is not '\'' and not '’'; + + private static Task InstallWordRecorderAsync(IPage page, string selector) => + page.EvaluateAsync( + """ + config => { + const recorder = { + lastWord: null, + pollIntervalMs: config.pollIntervalMs, + samples: [], + selector: config.selector, + startMs: performance.now(), + timer: 0 + }; + + const readWord = () => { + const node = document.querySelector(recorder.selector); + if (!(node instanceof HTMLElement)) { + return; + } + + const word = (node.textContent ?? '').trim(); + if (!word || word === recorder.lastWord) { + return; + } + + recorder.lastWord = word; + recorder.samples.push({ + atMs: Math.round(performance.now() - recorder.startMs), + durationMs: Number(node.dataset.ms ?? 0), + effectiveWpm: Number(node.dataset.effectiveWpm ?? 0), + pauseMs: Number(node.dataset.pauseMs ?? 0), + word + }); + }; + + readWord(); + recorder.timer = window.setInterval(readWord, recorder.pollIntervalMs); + window[config.key] = recorder; + } + """, + new + { + key = ReaderTimingRecorderKey, + pollIntervalMs = BrowserTestConstants.ReaderTiming.CapturePollIntervalMs, + selector + }); + + private static async Task> WaitForRecordedSamplesAsync(IPage page, int expectedSampleCount) + { + var startTime = DateTime.UtcNow; + while (DateTime.UtcNow - startTime < TimeSpan.FromMilliseconds(BrowserTestConstants.ReaderTiming.SampleCaptureTimeoutMs)) + { + var sampleCount = await ReadRecordedSampleCountAsync(page); + if (sampleCount >= expectedSampleCount) + { + break; + } + + await page.WaitForTimeoutAsync(BrowserTestConstants.ReaderTiming.CapturePollIntervalMs); + } + + var samples = await page.EvaluateAsync( + """ + key => { + const recorder = window[key]; + if (recorder?.timer) { + window.clearInterval(recorder.timer); + recorder.timer = 0; + } + + return recorder?.samples ?? []; + } + """, + ReaderTimingRecorderKey); + + Assert.NotNull(samples); + Assert.True( + samples.Length >= expectedSampleCount, + $"Expected at least {expectedSampleCount} recorded word samples, but captured {samples.Length}."); + + return samples.Take(expectedSampleCount).ToArray(); + } + + private static Task ReadRecordedSampleCountAsync(IPage page) => + page.EvaluateAsync( + """ + key => window[key]?.samples?.length ?? 0 + """, + ReaderTimingRecorderKey); + + private sealed record LearnTimingExpectation(string Word, int DurationMs, int PauseMs); + + private sealed class RecordedWordSample + { + public int AtMs { get; set; } + + public int DurationMs { get; set; } + + public int EffectiveWpm { get; set; } + + public int PauseMs { get; set; } + + public string Word { get; set; } = string.Empty; + } +} diff --git a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs index 5b2cb59..1bf21cd 100644 --- a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs +++ b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.ScreenFlows.cs @@ -13,7 +13,7 @@ public static class EditorFlow public const string OpeningBlock = "Opening Block"; public const string PurposeBlock = "Purpose Block"; public const string BenefitsBlock = "Benefits Block"; - public const string LearnSpeedAfterIncrease = "310"; + public const string LearnSpeedAfterIncrease = "260"; public const int BenefitsSegmentIndex = 2; public const int BenefitsBlockIndex = 1; } @@ -60,7 +60,7 @@ public static class TeleprompterFlow public const string ProductLaunchVisionPronunciation = "ˈviʒən"; public const string ProductLaunchVisionWord = "vision"; public const string ProductLaunchWarmWord = "Let"; - public const string SpeedOffsetsFastWord = "flight"; + public const string SpeedOffsetsFastWord = "flight."; public const string SpeedOffsetsFastWpm = "154"; public const string SpeedOffsetsNormalWord = "center"; public const string SpeedOffsetsNormalWpm = "140"; @@ -181,4 +181,30 @@ public static class TeleprompterFlow } """; } + + public static class ReaderTiming + { + public const int CapturePollIntervalMs = 20; + public const int LearnTimingToleranceMs = 200; + public const int SampleCaptureTimeoutMs = 12000; + public const int TeleprompterTimingToleranceMs = 180; + public const int WordCount = 5; + public const string FirstWord = "alpha"; + public const string SecondWord = "bravo"; + public const string ThirdWord = "charlie"; + public const string FourthWord = "delta"; + public const string FifthWord = "echo"; + public const int BaseWpm = 250; + public const int SlowWpm = 190; + public const int FastWpm = 310; + + public static IReadOnlyList ExpectedWords => + [ + FirstWord, + SecondWord, + ThirdWord, + FourthWord, + FifthWord + ]; + } } diff --git a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs index 055ac67..92db11b 100644 --- a/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs +++ b/tests/PrompterOne.App.UITests/Support/BrowserTestConstants.cs @@ -29,11 +29,13 @@ public static class Scripts public const string DemoId = "test-product-launch-script"; public const string LeadershipId = "test-ted-leadership-script"; public const string QuantumId = "test-quantum-computing-script"; + public const string ReaderTimingId = "test-reader-timing-script"; public const string SecurityIncidentId = "test-security-incident-script"; public const string SpeedOffsetsId = "test-tps-speed-offsets-script"; public const string ProductLaunchTitle = "Product Launch"; public const string LeadershipTitle = "TED: Leadership"; public const string QuantumTitle = "Quantum Computing"; + public const string ReaderTimingTitle = "Reader Timing Probe"; public const string SecurityIncidentTitle = "Security Incident"; public const string SpeedOffsetsTitle = "TPS Speed Offsets"; } @@ -418,6 +420,39 @@ async connect(url, token) { BitrateKbps: 6000, ShowTextOverlay: true, IncludeCameraInOutput: true, + ExternalDestinations: [ + { + Id: 'livekit', + Name: 'LiveKit', + ProviderKind: 0, + PlatformKind: 0, + IsEnabled: true, + ServerUrl: liveKitServer, + RoomName: liveKitRoom, + Token: liveKitToken, + PublishUrl: null, + Destinations: [] + }, + { + Id: 'youtube-live', + Name: 'YouTube Live', + ProviderKind: 2, + PlatformKind: 2, + IsEnabled: true, + ServerUrl: null, + RoomName: null, + Token: null, + PublishUrl: null, + Destinations: [ + { + Name: 'YouTube Live', + Url: youtubeUrl, + StreamKey: youtubeKey, + IsEnabled: true + } + ] + } + ], DestinationSourceSelections: [ { TargetId: 'obs-studio', SourceIds: [primarySourceId] }, { TargetId: 'local-recording', SourceIds: [primarySourceId] }, @@ -429,16 +464,16 @@ async connect(url, token) { ObsVirtualCameraEnabled: true, NdiOutputEnabled: false, LocalRecordingEnabled: true, - LiveKitEnabled: true, - LiveKitServerUrl: liveKitServer, - LiveKitRoomName: liveKitRoom, - LiveKitToken: liveKitToken, + LiveKitEnabled: false, + LiveKitServerUrl: '', + LiveKitRoomName: '', + LiveKitToken: '', VdoNinjaEnabled: false, VdoNinjaRoomName: '', VdoNinjaPublishUrl: '', - YoutubeEnabled: true, - YoutubeRtmpUrl: youtubeUrl, - YoutubeStreamKey: youtubeKey, + YoutubeEnabled: false, + YoutubeRtmpUrl: '', + YoutubeStreamKey: '', TwitchEnabled: false, TwitchRtmpUrl: '', TwitchStreamKey: '', @@ -459,11 +494,18 @@ async connect(url, token) { const parsed = JSON.parse(raw); const streaming = parsed?.Streaming; + const destinations = Array.isArray(streaming?.ExternalDestinations) + ? streaming.ExternalDestinations + : []; + + const hasEnabledDestination = (id) => + destinations.some(destination => destination?.Id === id && destination?.IsEnabled === true); + return Boolean( streaming?.ObsVirtualCameraEnabled === true && streaming?.LocalRecordingEnabled === true && - streaming?.LiveKitEnabled === true && - streaming?.YoutubeEnabled === true); + hasEnabledDestination('livekit') && + hasEnabledDestination('youtube-live')); } """; public const string PreviewReadyScript = "(element) => Boolean(element && element.srcObject && element.readyState >= 2)"; @@ -553,12 +595,14 @@ public static class Routes public static string LearnDemo => AppRoutes.LearnWithId(Scripts.DemoId); public static string LearnLeadership => AppRoutes.LearnWithId(Scripts.LeadershipId); public static string LearnQuantum => AppRoutes.LearnWithId(Scripts.QuantumId); + public static string LearnReaderTiming => AppRoutes.LearnWithId(Scripts.ReaderTimingId); public static string LearnSecurityIncident => AppRoutes.LearnWithId(Scripts.SecurityIncidentId); public static string GoLiveDemo => AppRoutes.GoLiveWithId(Scripts.DemoId); public static string TeleprompterDemo => AppRoutes.TeleprompterWithId(Scripts.DemoId); public static string TeleprompterLeadership => AppRoutes.TeleprompterWithId(Scripts.LeadershipId); public static string TeleprompterSecurityIncident => AppRoutes.TeleprompterWithId(Scripts.SecurityIncidentId); public static string TeleprompterQuantum => AppRoutes.TeleprompterWithId(Scripts.QuantumId); + public static string TeleprompterReaderTiming => AppRoutes.TeleprompterWithId(Scripts.ReaderTimingId); public static string TeleprompterSpeedOffsets => AppRoutes.TeleprompterWithId(Scripts.SpeedOffsetsId); public static string Pattern(string route) => string.Concat("**", route); diff --git a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs index f9cf85a..80ab645 100644 --- a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs +++ b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFidelityTests.cs @@ -192,7 +192,7 @@ await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)) await Expect(page.GetByTestId(UiTestIds.Teleprompter.WidthSlider)).ToHaveValueAsync(MaximumReaderWidthValue); await Expect(page.Locator($"#{UiDomIds.Teleprompter.WidthValue}")).ToHaveTextAsync(MaximumReaderWidthValue); await Expect(page.GetByTestId(UiTestIds.Teleprompter.CardGroup(SecurityIncidentResponseCardIndex, 0))) - .ToHaveClassAsync(new($@"\b{ContinuousEmphasisCssClass}\b")); + .ToHaveClassAsync(new Regex($@"\b{ContinuousEmphasisCssClass}\b")); var clusterWrapWidth = await page.Locator($"#{UiDomIds.Teleprompter.ClusterWrap}") .EvaluateAsync("element => getComputedStyle(element).maxWidth"); diff --git a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFullFlowTests.cs b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFullFlowTests.cs index 07f1d39..1d7026b 100644 --- a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFullFlowTests.cs +++ b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterFullFlowTests.cs @@ -117,6 +117,7 @@ private static async Task AssertProductLaunchTpsRenderingAsync(Microsoft.Playwri var purpleWord = await GetWordProbeAsync(page, InspirationCardIndex, BrowserTestConstants.TeleprompterFlow.ProductLaunchPurpleWord); var teleprompterWord = await GetWordProbeAsync(page, ClosingCardIndex, BrowserTestConstants.TeleprompterFlow.ProductLaunchTeleprompterWord); + Assert.DoesNotContain("tps-neutral", neutralWord.Classes, StringComparison.Ordinal); Assert.DoesNotContain("tps-warm", neutralWord.Classes, StringComparison.Ordinal); Assert.DoesNotContain("tps-focused", neutralWord.Classes, StringComparison.Ordinal); diff --git a/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterStylesheetFlowTests.cs b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterStylesheetFlowTests.cs new file mode 100644 index 0000000..0866576 --- /dev/null +++ b/tests/PrompterOne.App.UITests/Teleprompter/TeleprompterStylesheetFlowTests.cs @@ -0,0 +1,38 @@ +using PrompterOne.Shared.Contracts; +using static Microsoft.Playwright.Assertions; + +namespace PrompterOne.App.UITests; + +public sealed class TeleprompterStylesheetFlowTests(StandaloneAppFixture fixture) : AppUiTestBase(fixture), IClassFixture +{ + private const string HasTeleprompterStylesheetScript = """ + teleprompterHref => Array.from(document.styleSheets) + .map(sheet => sheet.href ?? "") + .some(href => { + if (!href) { + return false; + } + + return new URL(href).pathname === teleprompterHref; + }) + """; + + [Fact] + public Task TeleprompterStylesheet_IsRegisteredBeforeTeleprompterRouteEntry() => + RunPageAsync(async page => + { + await page.GotoAsync(BrowserTestConstants.Routes.Library); + await Expect(page.GetByTestId(UiTestIds.Library.Page)) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + + var hasTeleprompterStylesheet = await page.EvaluateAsync( + HasTeleprompterStylesheetScript, + DesignStylesheetPaths.Teleprompter); + + Assert.True(hasTeleprompterStylesheet); + + await page.GotoAsync(BrowserTestConstants.Routes.TeleprompterDemo); + await Expect(page.GetByTestId(UiTestIds.Teleprompter.Page)) + .ToBeVisibleAsync(new() { Timeout = BrowserTestConstants.Timing.ExtendedVisibleTimeoutMs }); + }); +} diff --git a/tests/PrompterOne.Core.Tests/Streaming/GoLiveDestinationRoutingTests.cs b/tests/PrompterOne.Core.Tests/Streaming/GoLiveDestinationRoutingTests.cs index a4f6a11..12b5e4f 100644 --- a/tests/PrompterOne.Core.Tests/Streaming/GoLiveDestinationRoutingTests.cs +++ b/tests/PrompterOne.Core.Tests/Streaming/GoLiveDestinationRoutingTests.cs @@ -1,4 +1,5 @@ using PrompterOne.Core.Models.Media; +using PrompterOne.Core.Models.Streaming; using PrompterOne.Core.Models.Workspace; using PrompterOne.Core.Services.Streaming; @@ -13,7 +14,11 @@ public sealed class GoLiveDestinationRoutingTests [Fact] public void Normalize_SeedsMissingTargetsFromProgramFeedSources() { - var streaming = new StreamStudioSettings(); + var streaming = new StreamStudioSettings( + ExternalDestinations: + [ + StreamingPlatformCatalog.CreateProfile(StreamingPlatformKind.LiveKit, GoLiveTargetCatalog.TargetIds.LiveKit) + ]); var normalized = GoLiveDestinationRouting.Normalize(streaming, CreateSceneCameras()); @@ -22,14 +27,21 @@ public void Normalize_SeedsMissingTargetsFromProgramFeedSources() GoLiveTargetCatalog.TargetIds.LiveKit, CreateSceneCameras()); - Assert.Equal(GoLiveTargetCatalog.AllTargetIds.Count, normalized.DestinationSourceSelections?.Count); + Assert.Equal(GoLiveTargetCatalog.LocalTargetIds.Count + 1, normalized.DestinationSourceSelections?.Count); Assert.Equal([FirstSourceId], liveKitSources); } [Fact] public void ToggleSource_UpdatesOnlyRequestedTarget() { - var streaming = GoLiveDestinationRouting.Normalize(new StreamStudioSettings(), CreateSceneCameras()); + var streaming = GoLiveDestinationRouting.Normalize( + new StreamStudioSettings( + ExternalDestinations: + [ + StreamingPlatformCatalog.CreateProfile(StreamingPlatformKind.LiveKit, GoLiveTargetCatalog.TargetIds.LiveKit), + StreamingPlatformCatalog.CreateProfile(StreamingPlatformKind.Youtube, GoLiveTargetCatalog.TargetIds.Youtube) + ]), + CreateSceneCameras()); var updated = GoLiveDestinationRouting.ToggleSource( streaming, @@ -49,6 +61,10 @@ public void ToggleSource_UpdatesOnlyRequestedTarget() public void Normalize_RemovesUnknownSourcesFromPersistedSelections() { var streaming = new StreamStudioSettings( + ExternalDestinations: + [ + StreamingPlatformCatalog.CreateProfile(StreamingPlatformKind.LiveKit, GoLiveTargetCatalog.TargetIds.LiveKit) + ], DestinationSourceSelections: [ new GoLiveDestinationSourceSelection( diff --git a/tests/PrompterOne.Core.Tests/Workspace/LearnSettingsDefaultsTests.cs b/tests/PrompterOne.Core.Tests/Workspace/LearnSettingsDefaultsTests.cs new file mode 100644 index 0000000..0fd4f1a --- /dev/null +++ b/tests/PrompterOne.Core.Tests/Workspace/LearnSettingsDefaultsTests.cs @@ -0,0 +1,20 @@ +using PrompterOne.Core.Models.Workspace; + +namespace PrompterOne.Core.Tests; + +public sealed class LearnSettingsDefaultsTests +{ + [Fact] + public void LearnSettings_DefaultConstructor_UsesWorkspaceDefaults() + { + var settings = new LearnSettings(); + + Assert.Equal(LearnSettingsDefaults.HasCustomizedWordsPerMinute, settings.HasCustomizedWordsPerMinute); + Assert.Equal(LearnSettingsDefaults.WordsPerMinute, settings.WordsPerMinute); + Assert.Equal(LearnSettingsDefaults.ContextWords, settings.ContextWords); + Assert.Equal(LearnSettingsDefaults.IgnoreScriptSpeeds, settings.IgnoreScriptSpeeds); + Assert.Equal(LearnSettingsDefaults.AutoPlay, settings.AutoPlay); + Assert.Equal(LearnSettingsDefaults.LoopPlayback, settings.LoopPlayback); + Assert.Equal(LearnSettingsDefaults.ShowPhrasePreview, settings.ShowPhrasePreview); + } +} diff --git a/tests/TestData/Scripts/test-reader-timing.tps b/tests/TestData/Scripts/test-reader-timing.tps new file mode 100644 index 0000000..fd2053e --- /dev/null +++ b/tests/TestData/Scripts/test-reader-timing.tps @@ -0,0 +1,15 @@ +--- +title: "Reader Timing Probe" +profile: "Actor" +base_wpm: 250 +speed_offsets: + slow: -60 + fast: 60 +version: "1.0" +--- + +## [Timing Probe|250WPM|neutral] + +### [Sequence Block|250WPM] + +alpha [slow]bravo[/slow] charlie [fast]delta[/fast] echo From 27b9db0b48cffc28b906d04d3e21a80061190f3f Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Thu, 2 Apr 2026 02:12:06 +0200 Subject: [PATCH 3/5] Fix reader and Go Live validation regressions --- AGENTS.md | 2 + docs/Architecture.md | 1 + .../AppShell/Layout/MainLayout.razor.cs | 5 +- .../AppShell/Services/AppShellService.cs | 86 +++++++++- src/PrompterOne.Shared/Contracts/UiTestIds.cs | 7 +- .../GoLiveCameraPreviewCard.razor.css | 6 +- .../GoLiveProgramFeedCard.razor.css | 6 +- .../GoLive/Models/GoLiveText.cs | 2 +- .../GoLive/Pages/GoLivePage.Bootstrap.cs | 6 +- .../GoLive/Pages/GoLivePage.Runtime.cs | 2 +- .../GoLive/Pages/GoLivePage.Session.cs | 4 +- .../GoLive/Pages/GoLivePage.StudioSurface.cs | 4 +- .../GoLive/Pages/GoLivePage.razor | 7 +- .../GoLive/Pages/GoLivePage.razor.cs | 17 +- .../Learn/Pages/LearnPage.DisplayState.cs | 59 +++---- .../Learn/Pages/LearnPage.Playback.cs | 6 +- .../Learn/Pages/LearnPage.Timeline.cs | 159 +++++++++++++---- .../Learn/Pages/LearnPage.razor.cs | 34 ++-- .../Settings/Components/SettingsSelect.razor | 8 + .../AppShell/ScreenShellContractTests.cs | 4 +- .../GoLive/GoLiveCameraPreviewTests.cs | 29 ++++ .../GoLive/GoLiveLiveIndicatorsTests.cs | 1 - .../GoLive/GoLivePageTests.cs | 76 +++++++-- .../Settings/SettingsInteractionTests.cs | 4 +- .../Support/AppTestData.cs | 4 + .../Support/AppTestLibrarySeedData.cs | 6 + .../Support/BunitSettingsSelectDriver.cs | 26 +++ .../Support/TestSupport.cs | 1 + .../GoLive/GoLiveFlowTests.cs | 34 +++- .../GoLive/GoLiveLiveIndicatorsFlowTests.cs | 5 + .../GoLive/GoLiveShellSessionFlowTests.cs | 7 +- .../Media/MediaRuntimeIntegrationTests.cs | 4 +- .../Reader/ReaderPlaybackTimingTests.cs | 160 ++++++++++++++---- .../Scenarios/StudioWorkflowScenarioTests.cs | 5 +- .../Settings/SettingsCloudStorageFlowTests.cs | 8 +- .../BrowserTestConstants.ScreenFlows.cs | 30 +++- .../Support/BrowserTestConstants.cs | 10 +- .../Support/BrowserTestLibrarySeedData.cs | 12 ++ .../Support/SettingsSelectDriver.cs | 22 +++ .../TeleprompterSettingsFlowTests.cs | 6 +- .../TeleprompterStylesheetFlowTests.cs | 9 +- .../Scripts/test-learn-wpm-boundary.tps | 14 ++ 42 files changed, 710 insertions(+), 188 deletions(-) create mode 100644 tests/PrompterOne.App.Tests/Support/BunitSettingsSelectDriver.cs create mode 100644 tests/PrompterOne.App.UITests/Support/SettingsSelectDriver.cs create mode 100644 tests/TestData/Scripts/test-learn-wpm-boundary.tps diff --git a/AGENTS.md b/AGENTS.md index 88606c2..1e61ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,6 +320,8 @@ Repo-specific design rules: - App-shell logo navigation MUST always lead to the main home/library screen; it must not deep-link into Go Live, Teleprompter, or another feature-specific route. - Learn rehearsal speed MUST default to about 250 WPM and stay user-adjustable upward from that baseline; shipping a 300 WPM startup default is too aggressive. - Go Live `ON AIR` badges and preview live dots MUST appear only while recording or streaming is actually active; idle selected or armed sources must stay visually non-live. +- Go Live chrome MUST stay operational and generic; do not surface the loaded script title or script preview subtitle in the Go Live header/session bar just because a script is open. +- Go Live back navigation MUST return to the actual previous in-app screen when known, and only fall back to library when there is no valid in-app return target; it must never hardcode teleprompter as the back target. - Learn and Teleprompter are separate screens with separate style ownership; do not bundle RSVP and teleprompter reader feature styles into one shared screen stylesheet or let one page inherit the other page's visual treatment. - User preferences persistence MUST sit behind a platform-agnostic user-settings abstraction, with browser storage implemented via local storage and room for other platform-specific implementations; theme, teleprompter layout preferences, camera/scene preferences, and similar saved settings belong there instead of ad-hoc feature stores. - Streaming destination/platform configuration MUST be user-defined and persisted in settings; Settings and Go Live must not ship hardcoded platform instances, seeded destination accounts, or fixed fake provider rows beyond real runtime capabilities. diff --git a/docs/Architecture.md b/docs/Architecture.md index e36c4f6..287b027 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -173,6 +173,7 @@ flowchart LR - `BrowserThemeService` is the first concrete remote consumer and keeps shell appearance aligned across tabs without reload. - `GoLiveSessionService` is the current publisher and consumer for active `Go Live` session snapshots, including startup catch-up requests. - `MainLayout` consumes `GoLiveSessionService` and renders the global shell `Go Live` status for every screen. +- `AppShellService` owns the current in-app route and the last valid non-`Go Live` return target so the `Go Live` back control can return to the actual previous screen instead of a hardcoded reader route. - The contract must stay message-based; tabs do not share live .NET memory. ## Library Contracts diff --git a/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs b/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs index 4af4d45..0d895db 100644 --- a/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs +++ b/src/PrompterOne.Shared/AppShell/Layout/MainLayout.razor.cs @@ -51,7 +51,6 @@ public partial class MainLayout : LayoutComponentBase, IDisposable private string HeaderSubtitle => ShellState.Screen switch { AppShellScreen.Teleprompter => ShellState.Subtitle, - AppShellScreen.GoLive => ShellState.Title, AppShellScreen.Learn => ShellState.Subtitle, _ => string.Empty }; @@ -131,6 +130,7 @@ protected override void OnInitialized() Navigation.LocationChanged += HandleLocationChanged; Shell.StateChanged += HandleShellStateChanged; GoLiveSession.StateChanged += HandleGoLiveSessionChanged; + Shell.TrackNavigation(Navigation.Uri); SyncShellStateWithCurrentRoute(Navigation.Uri); } @@ -154,6 +154,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) { Logger.LogInformation(RouteChangedLogTemplate, e.Location); + Shell.TrackNavigation(e.Location); SyncShellStateWithCurrentRoute(e.Location); StateHasChanged(); } @@ -182,7 +183,7 @@ private void SyncShellStateWithCurrentRoute(string uri) Shell.ShowTeleprompter(ShellState.Title, ShellState.Subtitle, currentScriptId); break; case AppRoutes.GoLive: - Shell.ShowGoLive(ShellState.Title, ShellState.Subtitle, currentScriptId); + Shell.ShowGoLive(currentScriptId); break; case AppRoutes.Settings: Shell.ShowSettings(); diff --git a/src/PrompterOne.Shared/AppShell/Services/AppShellService.cs b/src/PrompterOne.Shared/AppShell/Services/AppShellService.cs index ca27547..a7a7913 100644 --- a/src/PrompterOne.Shared/AppShell/Services/AppShellService.cs +++ b/src/PrompterOne.Shared/AppShell/Services/AppShellService.cs @@ -1,9 +1,16 @@ using PrompterOne.Shared.Contracts; +using PrompterOne.Shared.GoLive.Models; namespace PrompterOne.Shared.Services; public sealed class AppShellService { + private const string EmptyRoute = ""; + private const string QuerySeparator = "?"; + + private string _currentRoute = AppRoutes.Library; + private string _goLiveBackRoute = AppRoutes.Library; + public event Action? StateChanged; public event Action? LibrarySearchChanged; @@ -34,8 +41,13 @@ public void ShowLearn(string title, string subtitle, string wpmLabel, string? sc public void ShowTeleprompter(string title, string subtitle, string? scriptId) => SetScriptScopedState(AppShellScreen.Teleprompter, title, subtitle, string.Empty, scriptId); - public void ShowGoLive(string title, string subtitle, string? scriptId) => - SetScriptScopedState(AppShellScreen.GoLive, title, subtitle, string.Empty, scriptId); + public void ShowGoLive(string? scriptId) => + SetScriptScopedState( + AppShellScreen.GoLive, + GoLiveText.Chrome.ScreenTitle, + GoLiveText.Chrome.StreamingSubtitle, + string.Empty, + scriptId); public void ShowSettings() => SetState(new AppShellState( @@ -67,6 +79,26 @@ public void UpdateLibrarySearch(string searchText) public string GetGoLiveRoute() => BuildScriptScopedRoute(AppShellScreen.GoLive); + public string GetGoLiveBackRoute() => IsValidGoLiveBackTarget(_goLiveBackRoute) + ? _goLiveBackRoute + : AppRoutes.Library; + + public void TrackNavigation(string uri) + { + var nextRoute = NormalizeAppRoute(uri); + if (string.IsNullOrWhiteSpace(nextRoute) || string.Equals(_currentRoute, nextRoute, StringComparison.Ordinal)) + { + return; + } + + if (IsGoLiveRoute(nextRoute) && IsValidGoLiveBackTarget(_currentRoute)) + { + _goLiveBackRoute = _currentRoute; + } + + _currentRoute = nextRoute; + } + private void SetScriptScopedState( AppShellScreen screen, string title, @@ -108,4 +140,54 @@ private string BuildScriptScopedRoute(AppShellScreen screen) _ => AppRoutes.Library }; } + + private static bool IsGoLiveRoute(string route) + { + var querySeparatorIndex = route.IndexOf(QuerySeparator, StringComparison.Ordinal); + var routeBase = querySeparatorIndex >= 0 + ? route[..querySeparatorIndex] + : route; + + return string.Equals(routeBase, AppRoutes.GoLive, StringComparison.Ordinal); + } + + private static bool IsTrackedRoute(string path) => path switch + { + AppRoutes.Library => true, + AppRoutes.Editor => true, + AppRoutes.Learn => true, + AppRoutes.Teleprompter => true, + AppRoutes.GoLive => true, + AppRoutes.Settings => true, + _ => false + }; + + private static bool IsValidGoLiveBackTarget(string route) => + !string.IsNullOrWhiteSpace(route) && !IsGoLiveRoute(route); + + private static string NormalizeAppRoute(string uri) + { + if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsedUri)) + { + return EmptyRoute; + } + + var normalizedPath = NormalizePath(parsedUri.AbsolutePath); + if (!IsTrackedRoute(normalizedPath)) + { + return EmptyRoute; + } + + return string.IsNullOrWhiteSpace(parsedUri.Query) + ? normalizedPath + : string.Concat(normalizedPath, parsedUri.Query); + } + + private static string NormalizePath(string path) + { + var trimmedPath = path.TrimEnd('/'); + return string.IsNullOrWhiteSpace(trimmedPath) + ? AppRoutes.Library + : trimmedPath; + } } diff --git a/src/PrompterOne.Shared/Contracts/UiTestIds.cs b/src/PrompterOne.Shared/Contracts/UiTestIds.cs index e5012d3..89ec46c 100644 --- a/src/PrompterOne.Shared/Contracts/UiTestIds.cs +++ b/src/PrompterOne.Shared/Contracts/UiTestIds.cs @@ -331,6 +331,9 @@ public static class Settings public static string SceneMirror(string sourceId) => $"settings-scene-mirror-{sourceId}"; + public static string SelectOption(string triggerTestId, string optionValue) => + $"{triggerTestId}-option-{optionValue}"; + public static string StreamingProviderCard(string providerId) => $"settings-streaming-provider-{providerId}"; public static string StreamingProviderSourcePicker(string providerId) => $"settings-streaming-provider-sources-{providerId}"; @@ -377,6 +380,7 @@ public static class GoLive public const string ActiveSourceLabel = "go-live-active-source-label"; public const string AddSource = "go-live-add-source"; public const string AudioMixer = "go-live-audio-mixer"; + public const string Back = "go-live-back"; public const string Bitrate = "go-live-bitrate"; public const string CreateRoom = "go-live-create-room"; public const string CustomRtmpKey = "go-live-custom-rtmp-key"; @@ -393,7 +397,7 @@ public static class GoLive public const string ModeStudio = "go-live-mode-studio"; public const string NdiToggle = "go-live-ndi-toggle"; public const string ObsToggle = "go-live-obs-toggle"; - public const string OpenHome = "go-live-open-home"; + public const string OpenHome = Back; public const string OpenLearn = "go-live-open-learn"; public const string OpenRead = "go-live-open-read"; public const string OpenSettings = "go-live-open-settings"; @@ -418,6 +422,7 @@ public static class GoLive public const string StreamTab = "go-live-stream-tab"; public const string SceneBar = "go-live-scene-bar"; public const string SceneControls = "go-live-scene-controls"; + public const string ScreenTitle = "go-live-screen-title"; public const string SessionBar = "go-live-session-bar"; public const string SessionTimer = "go-live-session-timer"; public const string SourceRail = "go-live-source-rail"; diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor.css b/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor.css index b1afa62..54fe17b 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor.css +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor.css @@ -65,12 +65,14 @@ width: 7px; height: 7px; border-radius: 50%; - background: rgba(239, 68, 68, .2); - transition: background .3s; + background: rgba(255, 245, 224, .18); + border: 1px solid rgba(255, 245, 224, .16); + transition: background .3s, border-color .3s, box-shadow .3s; } .gl-air-dot.gl-air-dot-live { background: #ef4444; + border-color: rgba(239, 68, 68, .65); box-shadow: 0 0 6px rgba(239, 68, 68, .7); } diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveProgramFeedCard.razor.css b/src/PrompterOne.Shared/GoLive/Components/GoLiveProgramFeedCard.razor.css index 53b2c50..97305d9 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveProgramFeedCard.razor.css +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveProgramFeedCard.razor.css @@ -4,7 +4,7 @@ gap: 4px; min-width: 0; min-height: 0; - flex: 1; + flex: 0 0 auto; } .gl-monitor-label-top { @@ -35,7 +35,8 @@ } .gl-monitor-program { - flex: 1; + flex: 0 0 auto; + aspect-ratio: 16 / 9; min-height: 0; border-color: var(--gold-14); box-shadow: 0 0 40px rgba(0, 0, 0, .4), inset 0 0 0 1px var(--gold-06); @@ -44,6 +45,7 @@ ::deep .gl-monitor-feed { flex: 1; position: relative; + aspect-ratio: 16 / 9; min-height: 0; overflow: hidden; background: #050508; diff --git a/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs b/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs index 3c49e66..49cd46c 100644 --- a/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs +++ b/src/PrompterOne.Shared/GoLive/Models/GoLiveText.cs @@ -7,6 +7,7 @@ public static class Chrome public const string BackLabel = "Back"; public const string DirectorModeLabel = "Director"; public const string LivePreviewTitle = "Live"; + public const string ScreenTitle = "Go Live"; public const string StreamingSubtitle = "Program routing"; public const string StudioModeLabel = "Studio"; } @@ -84,7 +85,6 @@ public static class Surface public const string HostParticipantName = "Host"; public const string InterviewSceneFallback = "Interview"; public const string LocalRoomPrefix = "local-"; - public const string MainSceneFallback = "Camera 1"; public const string MicrophoneMetricLabel = "Mic"; public const string NoScriptProgressLabel = "No script loaded"; public const string PictureInPictureSceneId = "scene-picture-in-picture"; diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs index 067835d..f890e47 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Bootstrap.cs @@ -146,13 +146,13 @@ private async Task EnsureSessionLoadedAsync() private void UpdateScreenMetadata() { - _screenTitle = SessionService.State.Title; - _screenSubtitle = SessionService.State.PreviewSegments.Count > 0 + _sessionTitle = SessionService.State.Title; + _sessionSubtitle = SessionService.State.PreviewSegments.Count > 0 ? SessionService.State.PreviewSegments[0].Title : GoLiveText.Chrome.StreamingSubtitle; SyncGoLiveSessionState(); EnsureStudioSurfaceState(); - Shell.ShowGoLive(_screenTitle, _screenSubtitle, SessionService.State.ScriptId); + Shell.ShowGoLive(SessionService.State.ScriptId); } private async Task PersistSceneAsync() diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs index 8296e48..16f036e 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs @@ -11,5 +11,5 @@ private GoLiveOutputRuntimeRequest BuildRuntimeRequest(SceneCameraSource? camera MediaSceneService.State, _studioSettings.Streaming, _recordingPreferences, - _screenTitle); + _sessionTitle); } diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs index a1f2816..f16d7c2 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs @@ -84,8 +84,8 @@ private void SyncGoLiveSessionState() { GoLiveSession.EnsureSession( SessionService.State.ScriptId, - _screenTitle, - _screenSubtitle, + _sessionTitle, + _sessionSubtitle, PrimaryMicrophoneLabel, _studioSettings.Streaming, SceneCameras); diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs index 64ba168..212ae70 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs @@ -1,6 +1,5 @@ using System.Globalization; using PrompterOne.Core.Models.Media; -using PrompterOne.Core.Models.Workspace; using PrompterOne.Shared.Components.GoLive; using PrompterOne.Shared.GoLive.Models; @@ -13,7 +12,6 @@ public partial class GoLivePage private const string GoLiveHideLeftClass = "gl-hide-left"; private const string GoLiveHideRightClass = "gl-hide-right"; - private string _activeSceneId = GoLiveText.Surface.PrimarySceneId; private GoLiveSceneLayout _activeSceneLayout = GoLiveSceneLayout.Full; private GoLiveStudioMode _activeStudioMode = GoLiveStudioMode.Director; @@ -188,7 +186,7 @@ private IReadOnlyList BuildSceneChips() var secondaryCamera = SceneCameras.Count > 1 ? SceneCameras[1] : null; var scenes = new List { - new(GoLiveText.Surface.PrimarySceneId, primaryCamera?.Label ?? GoLiveText.Surface.MainSceneFallback, GoLiveSceneChipKind.Camera, primaryCamera?.SourceId), + new(GoLiveText.Surface.PrimarySceneId, primaryCamera?.Label ?? GoLiveText.Session.CameraFallbackLabel, GoLiveSceneChipKind.Camera, primaryCamera?.SourceId), new(GoLiveText.Surface.SecondarySceneId, secondaryCamera?.Label ?? GoLiveText.Surface.InterviewSceneFallback, GoLiveSceneChipKind.Split, secondaryCamera?.SourceId), new(GoLiveText.Surface.SceneSlidesId, GoLiveText.Surface.SceneSlidesLabel, GoLiveSceneChipKind.Slides, null), new(GoLiveText.Surface.PictureInPictureSceneId, GoLiveText.Surface.PictureInPictureSceneLabel, GoLiveSceneChipKind.PictureInPicture, primaryCamera?.SourceId) diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor index e932dda..61f4d8f 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor @@ -12,8 +12,8 @@ data-testid="@UiTestIds.GoLive.SessionBar">
+ href="@BackRoute" + data-testid="@UiTestIds.GoLive.Back"> @GoLiveText.Chrome.BackLabel - @_screenTitle + @ScreenTitle @PrimarySessionBadge } @@ -57,6 +58,7 @@ private int SelectedCount => SelectedSourceIdSet.Count; private HashSet SelectedSourceIdSet => SelectedSourceIds.ToHashSet(StringComparer.Ordinal); + private static string FormatSourceLabel(SceneCameraSource source) => MediaDeviceLabelSanitizer.Sanitize(source.Label); private string SourceSummary => SelectedCount switch diff --git a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor index 54cdd78..572bdea 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor @@ -1,4 +1,5 @@ @namespace PrompterOne.Shared.Components.GoLive +@using PrompterOne.Shared.Services
@@ -50,7 +51,7 @@
- @source.Label + @FormatSourceLabel(source) @GetSourceStatus(source)
@@ -85,7 +86,7 @@
- @MicrophoneName + @DisplayMicrophoneName @MicrophoneRouteLabel
@@ -117,6 +118,8 @@ [Parameter] public string Title { get; set; } = DefaultTitle; [Parameter] public EventCallback ToggleSource { get; set; } [Parameter] public IReadOnlyList UtilitySources { get; set; } = []; + private string DisplayMicrophoneName => MediaDeviceLabelSanitizer.Sanitize(MicrophoneName); + private static string FormatSourceLabel(SceneCameraSource source) => MediaDeviceLabelSanitizer.Sanitize(source.Label); private bool IsActive(SceneCameraSource source) => string.Equals(source.SourceId, ActiveSourceId, StringComparison.Ordinal); diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs index f16d7c2..15108cb 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs @@ -2,6 +2,7 @@ using PrompterOne.Core.Models.Media; using PrompterOne.Core.Models.Workspace; using PrompterOne.Shared.GoLive.Models; +using PrompterOne.Shared.Services; namespace PrompterOne.Shared.Pages; @@ -21,7 +22,9 @@ public partial class GoLivePage _ => GoLiveText.Session.SessionIdleLabel }; - private string ActiveSourceLabel => ActiveCamera?.Label ?? GoLiveText.Session.CameraFallbackLabel; + private string ActiveSourceLabel => ActiveCamera is null + ? GoLiveText.Session.CameraFallbackLabel + : MediaDeviceLabelSanitizer.Sanitize(ActiveCamera.Label); private bool CanControlProgram => SelectedCamera is not null; @@ -58,7 +61,9 @@ public partial class GoLivePage _ => SessionBadgeIdleCssClass }; - private string SelectedSourceLabel => SelectedCamera?.Label ?? GoLiveText.Session.CameraFallbackLabel; + private string SelectedSourceLabel => SelectedCamera is null + ? GoLiveText.Session.CameraFallbackLabel + : MediaDeviceLabelSanitizer.Sanitize(SelectedCamera.Label); private SceneCameraSource? SelectedCamera => ResolveSessionSource(GoLiveSession.State.SelectedSourceId) ?? ActiveCamera; diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs index 212ae70..611d620 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs @@ -2,6 +2,7 @@ using PrompterOne.Core.Models.Media; using PrompterOne.Shared.Components.GoLive; using PrompterOne.Shared.GoLive.Models; +using PrompterOne.Shared.Services; namespace PrompterOne.Shared.Pages; @@ -186,8 +187,8 @@ private IReadOnlyList BuildSceneChips() var secondaryCamera = SceneCameras.Count > 1 ? SceneCameras[1] : null; var scenes = new List { - new(GoLiveText.Surface.PrimarySceneId, primaryCamera?.Label ?? GoLiveText.Session.CameraFallbackLabel, GoLiveSceneChipKind.Camera, primaryCamera?.SourceId), - new(GoLiveText.Surface.SecondarySceneId, secondaryCamera?.Label ?? GoLiveText.Surface.InterviewSceneFallback, GoLiveSceneChipKind.Split, secondaryCamera?.SourceId), + new(GoLiveText.Surface.PrimarySceneId, primaryCamera is null ? GoLiveText.Session.CameraFallbackLabel : MediaDeviceLabelSanitizer.Sanitize(primaryCamera.Label), GoLiveSceneChipKind.Camera, primaryCamera?.SourceId), + new(GoLiveText.Surface.SecondarySceneId, secondaryCamera is null ? GoLiveText.Surface.InterviewSceneFallback : MediaDeviceLabelSanitizer.Sanitize(secondaryCamera.Label), GoLiveSceneChipKind.Split, secondaryCamera?.SourceId), new(GoLiveText.Surface.SceneSlidesId, GoLiveText.Surface.SceneSlidesLabel, GoLiveSceneChipKind.Slides, null), new(GoLiveText.Surface.PictureInPictureSceneId, GoLiveText.Surface.PictureInPictureSceneLabel, GoLiveSceneChipKind.PictureInPicture, primaryCamera?.SourceId) }; diff --git a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor.cs b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor.cs index eaf8a62..6d240fe 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.razor.cs @@ -43,7 +43,9 @@ public partial class GoLivePage : ComponentBase ?? SceneCameras.FirstOrDefault(camera => camera.Transform.Visible) ?? (SceneCameras.Count > 0 ? SceneCameras[0] : null); - private string PrimaryMicrophoneLabel => MediaSceneService.State.PrimaryMicrophoneLabel ?? GoLiveText.Audio.NoMicrophoneLabel; + private string PrimaryMicrophoneLabel => string.IsNullOrWhiteSpace(MediaSceneService.State.PrimaryMicrophoneLabel) + ? GoLiveText.Audio.NoMicrophoneLabel + : MediaDeviceLabelSanitizer.Sanitize(MediaSceneService.State.PrimaryMicrophoneLabel); private string BackRoute => Shell.GetGoLiveBackRoute(); diff --git a/src/PrompterOne.Shared/Media/Components/CameraPreviewTile.razor b/src/PrompterOne.Shared/Media/Components/CameraPreviewTile.razor index cfa9e87..1d90575 100644 --- a/src/PrompterOne.Shared/Media/Components/CameraPreviewTile.razor +++ b/src/PrompterOne.Shared/Media/Components/CameraPreviewTile.razor @@ -1,10 +1,11 @@ @namespace PrompterOne.Shared.Components @implements IAsyncDisposable @inject CameraPreviewInterop CameraPreviewInterop +@using PrompterOne.Shared.Services
-
@Camera.Label
+
@CameraLabel
@code { @@ -13,6 +14,7 @@ private string _elementId = $"camera-{Guid.NewGuid():N}"; private string? _attachedDeviceId; + private string CameraLabel => MediaDeviceLabelSanitizer.Sanitize(Camera.Label); private string TileStyle { diff --git a/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs b/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs index e9d23e5..23d2f98 100644 --- a/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs +++ b/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using Microsoft.JSInterop; using PrompterOne.Core.Abstractions; using PrompterOne.Core.Models.Media; @@ -17,7 +16,7 @@ public async Task> GetDevicesAsync(CancellationTo return devices.Select(device => new MediaDeviceInfo( device.DeviceId, - SanitizeLabel(device.Label), + MediaDeviceLabelSanitizer.Sanitize(device.Label), device.Kind switch { "videoinput" => MediaDeviceKind.Camera, @@ -28,19 +27,5 @@ public async Task> GetDevicesAsync(CancellationTo device.IsDefault)).ToList(); } - private static string SanitizeLabel(string? rawLabel) - { - if (string.IsNullOrWhiteSpace(rawLabel)) - { - return string.Empty; - } - - var cleaned = VendorIdPattern().Replace(rawLabel, string.Empty).Trim(); - return string.IsNullOrWhiteSpace(cleaned) ? string.Empty : cleaned; - } - - [GeneratedRegex(@"\s*\([0-9a-fA-F]{4}:[0-9a-fA-F]{4}\)")] - private static partial Regex VendorIdPattern(); - private sealed record BrowserMediaDeviceDto(string DeviceId, string? Label, string Kind, bool IsDefault); } diff --git a/src/PrompterOne.Shared/Media/Services/MediaDeviceLabelSanitizer.cs b/src/PrompterOne.Shared/Media/Services/MediaDeviceLabelSanitizer.cs new file mode 100644 index 0000000..f76b8ab --- /dev/null +++ b/src/PrompterOne.Shared/Media/Services/MediaDeviceLabelSanitizer.cs @@ -0,0 +1,22 @@ +using System.Text.RegularExpressions; + +namespace PrompterOne.Shared.Services; + +internal static partial class MediaDeviceLabelSanitizer +{ + public static string Sanitize(string? rawLabel) + { + if (string.IsNullOrWhiteSpace(rawLabel)) + { + return string.Empty; + } + + var cleaned = VendorProductCodeSuffixPattern().Replace(rawLabel, string.Empty).Trim(); + return string.IsNullOrWhiteSpace(cleaned) + ? string.Empty + : cleaned; + } + + [GeneratedRegex(@"\s*\([0-9a-fA-F]{4}:[0-9a-fA-F]{4}\)\s*$")] + private static partial Regex VendorProductCodeSuffixPattern(); +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor index 99eb376..0587faa 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor @@ -1,12 +1,13 @@ @namespace PrompterOne.Shared.Components.Settings @using PrompterOne.Shared.Pages @using PrompterOne.Shared.Settings.Components +@using PrompterOne.Shared.Settings.Models
-

AI Provider

+

@SettingsNavigationText.AiProviderLabel

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

diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsCameraPreviewCard.razor b/src/PrompterOne.Shared/Settings/Components/SettingsCameraPreviewCard.razor index ea3043e..39ef928 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsCameraPreviewCard.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsCameraPreviewCard.razor @@ -1,5 +1,6 @@ @implements IAsyncDisposable @namespace PrompterOne.Shared.Components +@using PrompterOne.Shared.Services @inject CameraPreviewInterop CameraPreviewInterop @inject Microsoft.Extensions.Logging.ILogger Logger @@ -72,7 +73,7 @@ private bool CanPreview => AccessGranted && !string.IsNullOrWhiteSpace(Camera?.DeviceId); - private string CameraLabel => Camera?.Label ?? string.Empty; + private string CameraLabel => MediaDeviceLabelSanitizer.Sanitize(Camera?.Label); private string EmptyDescription => !AccessGranted diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor index 3ef925f..f6ff424 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsFilesSection.razor @@ -1,11 +1,12 @@ @namespace PrompterOne.Shared.Components.Settings @using PrompterOne.Shared.Settings.Components +@using PrompterOne.Shared.Settings.Models
-

File Storage

+

@SettingsNavigationText.FileStorageLabel

Where scripts, recordings, and exports are saved locally.

diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor b/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor index ceab396..fb00878 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsMicrophoneLevelCard.razor @@ -2,6 +2,7 @@ @namespace PrompterOne.Shared.Components @using System.Globalization @using Microsoft.JSInterop +@using PrompterOne.Shared.Services @inject Microsoft.Extensions.Logging.ILogger Logger @inject MicrophoneLevelInterop MicrophoneLevelInterop @@ -120,7 +121,7 @@ ? PermissionTitle : NoMicrophoneTitle; - private string MicrophoneLabel => Microphone?.Label ?? string.Empty; + private string MicrophoneLabel => MediaDeviceLabelSanitizer.Sanitize(Microphone?.Label); private string MonitorDescription => CanRenderMonitor diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingSourcePicker.razor b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingSourcePicker.razor index e307042..bcbe056 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingSourcePicker.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingSourcePicker.razor @@ -1,4 +1,5 @@ @namespace PrompterOne.Shared.Components.Settings +@using PrompterOne.Shared.Services
@@ -27,7 +28,7 @@ class="live-destination-source-chip @(isSelected ? ChipEnabledCssClass : null)" data-testid="@UiTestIds.Settings.StreamingProviderSourceToggle(TargetId, source.SourceId)" @onclick="() => ToggleSource.InvokeAsync(source.SourceId)"> - @source.Label + @FormatSourceLabel(source) @(isSelected ? SelectedLabel : AvailableLabel) } @@ -55,6 +56,7 @@ private int SelectedCount => SelectedSourceIdSet.Count; private HashSet SelectedSourceIdSet => SelectedSourceIds.ToHashSet(StringComparer.Ordinal); + private static string FormatSourceLabel(SceneCameraSource source) => MediaDeviceLabelSanitizer.Sanitize(source.Label); private string SourceSummary => SelectedCount switch diff --git a/src/PrompterOne.Shared/Settings/Models/SettingsNavigationText.cs b/src/PrompterOne.Shared/Settings/Models/SettingsNavigationText.cs new file mode 100644 index 0000000..75b8a26 --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Models/SettingsNavigationText.cs @@ -0,0 +1,8 @@ +namespace PrompterOne.Shared.Settings.Models; + +public static class SettingsNavigationText +{ + public const string AiProviderLabel = "AI Provider"; + public const string CloudSyncLabel = "Cloud Sync"; + public const string FileStorageLabel = "File Storage"; +} diff --git a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor index 4d0baa0..9129478 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor @@ -5,8 +5,10 @@ @using PrompterOne.Core.Models.Media @using PrompterOne.Core.Models.Workspace @using PrompterOne.Shared.Components.Settings +@using PrompterOne.Shared.Services @using PrompterOne.Shared.Services.Diagnostics @using PrompterOne.Shared.Settings.Components +@using PrompterOne.Shared.Settings.Models @using PrompterOne.Shared.Storage @inject AppBootstrapper Bootstrapper @@ -28,11 +30,11 @@
- @camera.Label + @MediaDeviceLabelSanitizer.Sanitize(camera.Label) @BuildCameraMeta(camera, isPrimary)
@@ -332,7 +334,7 @@
- @microphone.Label + @MediaDeviceLabelSanitizer.Sanitize(microphone.Label) @BuildMicrophoneMeta(microphone)
diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs index c63555d..b7322d4 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs @@ -12,6 +12,9 @@ public partial class TeleprompterPage private const string ReaderCardPreviousCssClass = "rd-card-prev"; private const string ReaderControlButtonCssClass = "rd-ctrl-btn"; private const string ReaderCountdownCssClass = "rd-countdown"; + private const string ReaderGradientCssClass = "rd-gradient"; + private const string ReaderGradientDefaultCssClass = "neutral"; + private const string ReaderGradientNoTransitionCssClass = "rd-gradient-static"; private const string ReaderGroupActiveCssClass = "rd-g-active"; private const string ReaderGroupCssClass = "rd-g"; private const string ReaderGroupEmphasisCssClass = "rd-g-emphasis"; @@ -27,7 +30,7 @@ private void UpdateReaderDisplayState(bool instantAlignment = false, bool reques { if (_cards.Count == 0) { - _gradientClass = string.Empty; + _gradientClass = ReaderGradientDefaultCssClass; _edgeSectionLabel = string.Empty; _readerProgressFillWidth = "0%"; _elapsedLabel = "0:00 / 0:00"; @@ -69,6 +72,12 @@ private string BuildCameraCssClass() => private string BuildCameraTintCssClass() => BuildClassList(ReaderCameraTintCssClass, _isReaderCameraActive ? ActiveCssClass : null); + private string BuildReaderGradientCssClass() => + BuildClassList( + ReaderGradientCssClass, + string.IsNullOrWhiteSpace(_gradientClass) ? ReaderGradientDefaultCssClass : _gradientClass, + _isReaderGradientTransitionDisabled ? ReaderGradientNoTransitionCssClass : null); + private string BuildCameraButtonCssClass() => BuildClassList(ReaderControlButtonCssClass, _isReaderCameraActive ? ActiveCssClass : null); diff --git a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor index 2c7553b..999108d 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor @@ -10,7 +10,7 @@ @onkeydown:preventDefault="true" data-testid="@UiTestIds.Teleprompter.Page">
-
+