diff --git a/AGENTS.md b/AGENTS.md index 6d5b60c..2943f6f 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. @@ -304,10 +306,27 @@ 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 read-state styling MUST mute phrase-level underline or highlight accents once the emphasized text has been read; bright lingering underline accents on already-read text 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. +- 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. +- 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. @@ -359,6 +378,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 @@ -368,8 +388,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/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/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/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/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/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/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.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/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/AppBootstrapper.cs b/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs index 0df2d58..e22285b 100644 --- a/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs +++ b/src/PrompterOne.Shared/AppShell/Services/AppBootstrapper.cs @@ -57,15 +57,29 @@ 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); if (mediaScene is not null) { + var (normalizedMediaScene, mediaSceneChanged) = NormalizeMediaScene(mediaScene); + if (mediaSceneChanged) + { + _logger.LogInformation("Normalizing media scene labels from browser storage."); + await _settingsStore.SaveAsync(BrowserAppSettingsKeys.SceneSettings, normalizedMediaScene, cancellationToken); + } + _logger.LogInformation("Restoring media scene from browser storage."); - _mediaSceneService.ApplyState(mediaScene); + _mediaSceneService.ApplyState(normalizedMediaScene); } _initialized = true; @@ -76,4 +90,91 @@ 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; + } + + private static (MediaSceneState State, bool Changed) NormalizeMediaScene(MediaSceneState state) + { + var changed = false; + var normalizedCameras = state.Cameras + .Select(camera => + { + var normalizedLabel = MediaDeviceLabelSanitizer.Sanitize(camera.Label); + if (string.Equals(normalizedLabel, camera.Label, StringComparison.Ordinal)) + { + return camera; + } + + changed = true; + return camera with { Label = normalizedLabel }; + }) + .ToList(); + + var normalizedPrimaryMicrophoneLabel = NormalizeOptionalLabel(state.PrimaryMicrophoneLabel, ref changed); + var normalizedAudioInputs = state.AudioBus.Inputs + .Select(input => + { + var normalizedLabel = MediaDeviceLabelSanitizer.Sanitize(input.Label); + if (string.Equals(normalizedLabel, input.Label, StringComparison.Ordinal)) + { + return input; + } + + changed = true; + return input with { Label = normalizedLabel }; + }) + .ToList(); + + if (!changed) + { + return (state, false); + } + + return ( + state with + { + Cameras = normalizedCameras, + PrimaryMicrophoneLabel = normalizedPrimaryMicrophoneLabel, + AudioBus = state.AudioBus with { Inputs = normalizedAudioInputs } + }, + true); + } + + private static string? NormalizeOptionalLabel(string? value, ref bool changed) + { + if (value is null) + { + return null; + } + + var normalized = MediaDeviceLabelSanitizer.Sanitize(value); + if (!string.Equals(normalized, value, StringComparison.Ordinal)) + { + changed = true; + } + + return normalized; + } } 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/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 c608095..89ec46c 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"; @@ -328,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}"; @@ -335,6 +341,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 @@ -342,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"; @@ -358,6 +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 = 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"; @@ -369,6 +409,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"; @@ -381,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"; @@ -405,6 +447,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}"; @@ -414,6 +468,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..9d1ffc5 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveCameraPreviewCard.razor @@ -1,4 +1,5 @@ @namespace PrompterOne.Shared.Components.GoLive +@using PrompterOne.Shared.Services
@Title - @Camera?.Label + @CameraLabel
@@ -28,7 +29,9 @@
- +
@@ -38,15 +41,24 @@ 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 CameraLabel => Camera is null ? string.Empty : MediaDeviceLabelSanitizer.Sanitize(Camera.Label); + + private string PreviewIndicatorState => ShowLiveDot + ? LiveState + : IdleStateValue; + + private bool ShowLiveDot => HasCamera && !string.Equals(LiveState, IdleStateValue, StringComparison.Ordinal); } 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/GoLiveDestinationSourcePicker.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveDestinationSourcePicker.razor index dcf108e..a8213a6 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveDestinationSourcePicker.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveDestinationSourcePicker.razor @@ -1,4 +1,5 @@ @namespace PrompterOne.Shared.Components.GoLive +@using PrompterOne.Shared.Services
@@ -26,7 +27,7 @@ class="live-destination-source-chip @(isSelected ? ChipEnabledCssClass : null)" data-testid="@UiTestIds.GoLive.ProviderSourceToggle(TargetId, source.SourceId)" @onclick="() => ToggleSource.InvokeAsync(source.SourceId)"> - @source.Label + @FormatSourceLabel(source) @(isSelected ? SelectedLabel : AvailableLabel) } @@ -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/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/Components/GoLiveSourcesCard.razor b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor index 9aa874e..572bdea 100644 --- a/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor +++ b/src/PrompterOne.Shared/GoLive/Components/GoLiveSourcesCard.razor @@ -1,5 +1,5 @@ @namespace PrompterOne.Shared.Components.GoLive -@using PrompterOne.Core.Models.Workspace +@using PrompterOne.Shared.Services
@@ -43,46 +43,17 @@ VideoClass="gl-source-feed" VideoTestId="@UiTestIds.GoLive.SourceVideo(source.SourceId)"> - @GetSourceBadge(source) + @GetSourceBadge(source)
- @source.Label + @FormatSourceLabel(source) @GetSourceStatus(source)
- -
-
-
@GetSourceAvatarInitial(source)
- @GetSourceReaderLabel(source) -
- -
- @foreach (var crop in CropOptions) - { - var cropIsActive = GetCropSelection(source.SourceId) == crop; - - } -
-
- -
- - @CurrentScriptTitle - @CurrentScriptProgressLabel -
@@ -115,7 +86,7 @@
- @MicrophoneName + @DisplayMicrophoneName @MicrophoneRouteLabel
@@ -128,26 +99,17 @@ 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 IdleStateValue = "idle"; 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 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; } @@ -156,10 +118,18 @@ [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); + 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); @@ -175,52 +145,9 @@ : 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)) + if (IsOnAir(source)) { return SourceOnAirLabel; } @@ -232,27 +159,15 @@ : 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 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"); } @@ -267,7 +182,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/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/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..49cd46c --- /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 ScreenTitle = "Go 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 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..f890e47 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(); @@ -145,20 +146,20 @@ 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 - : StreamingSubtitle; + : GoLiveText.Chrome.StreamingSubtitle; SyncGoLiveSessionState(); EnsureStudioSurfaceState(); - Shell.ShowGoLive(_screenTitle, _screenSubtitle, SessionService.State.ScriptId); + Shell.ShowGoLive(SessionService.State.ScriptId); } 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..16f036e 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Runtime.cs @@ -5,18 +5,11 @@ 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, 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 13057e2..15108cb 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.Session.cs @@ -1,47 +1,30 @@ using System.Globalization; using PrompterOne.Core.Models.Media; using PrompterOne.Core.Models.Workspace; +using PrompterOne.Shared.GoLive.Models; +using PrompterOne.Shared.Services; 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 is null + ? GoLiveText.Session.CameraFallbackLabel + : MediaDeviceLabelSanitizer.Sanitize(ActiveCamera.Label); private bool CanControlProgram => SelectedCamera is not null; @@ -49,14 +32,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 +61,23 @@ public partial class GoLivePage _ => SessionBadgeIdleCssClass }; - private string SelectedSourceLabel => SelectedCamera?.Label ?? CameraFallbackLabel; + private string SelectedSourceLabel => SelectedCamera is null + ? GoLiveText.Session.CameraFallbackLabel + : MediaDeviceLabelSanitizer.Sanitize(SelectedCamera.Label); 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"; @@ -92,8 +89,8 @@ private void SyncGoLiveSessionState() { GoLiveSession.EnsureSession( SessionService.State.ScriptId, - _screenTitle, - _screenSubtitle, + _sessionTitle, + _sessionSubtitle, PrimaryMicrophoneLabel, _studioSettings.Streaming, SceneCameras); @@ -115,8 +112,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 +133,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 +172,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 +185,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 +204,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 +215,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 c4f4fcf..611d620 100644 --- a/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs +++ b/src/PrompterOne.Shared/GoLive/Pages/GoLivePage.StudioSurface.cs @@ -1,84 +1,19 @@ using System.Globalization; using PrompterOne.Core.Models.Media; -using PrompterOne.Core.Models.Workspace; 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 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 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"; - 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 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 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 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 string _activeSceneId = GoLiveText.Surface.PrimarySceneId; private GoLiveSceneLayout _activeSceneLayout = GoLiveSceneLayout.Full; private GoLiveStudioMode _activeStudioMode = GoLiveStudioMode.Director; private GoLiveStudioTab _activeStudioTab = GoLiveStudioTab.Stream; @@ -100,8 +35,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(); @@ -113,7 +47,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 + ? GoLiveText.Surface.DirectorSourcesTitle + : GoLiveText.Surface.SourcesTitle; private string GoLiveContentClass { @@ -169,23 +113,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) ]; } @@ -201,10 +145,10 @@ private IReadOnlyList BuildParticipants() return [ new( - HostParticipantId, - HostParticipantInitial, - HostParticipantName, - DetailLocalProgramLabel, + GoLiveText.Surface.HostParticipantId, + GoLiveText.Surface.HostParticipantInitial, + GoLiveText.Surface.HostParticipantName, + GoLiveText.Surface.DetailLocalProgramLabel, participantLevel, true) ]; @@ -212,27 +156,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) ]; } @@ -242,15 +187,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 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) }; 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; @@ -261,10 +210,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) ]; } @@ -272,127 +221,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; @@ -438,7 +286,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 f1b0ba2..61f4d8f 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
+ href="@BackRoute" + data-testid="@UiTestIds.GoLive.Back"> - Back + @GoLiveText.Chrome.BackLabel - @_screenTitle + @ScreenTitle @PrimarySessionBadge
- @RecordingIndicatorLabel + @GoLiveText.Session.RecordingIndicatorLabel
-
- @foreach (var word in _leftContextWords) - { - @word - } +
+
+ @foreach (var word in _leftContextWords) + { + @word + } +
-
+
@_currentWordLeading - @_currentWordOrp + @_currentWordOrp @_currentWordTrailing
-
- @foreach (var word in _rightContextWords) - { - @word - } +
+
+ @foreach (var word in _rightContextWords) + { + @word + } +
diff --git a/src/PrompterOne.Shared/Learn/Pages/LearnPage.razor.cs b/src/PrompterOne.Shared/Learn/Pages/LearnPage.razor.cs index d3a9251..f5dd768 100644 --- a/src/PrompterOne.Shared/Learn/Pages/LearnPage.razor.cs +++ b/src/PrompterOne.Shared/Learn/Pages/LearnPage.razor.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components; using PrompterOne.Core.Abstractions; +using PrompterOne.Core.Models.Workspace; using PrompterOne.Core.Services.Rsvp; using PrompterOne.Shared.Contracts; using PrompterOne.Shared.Services; @@ -9,7 +10,7 @@ namespace PrompterOne.Shared.Pages; public partial class LearnPage : IAsyncDisposable { - private const int DefaultContextWordCount = 3; + private const int DefaultContextWordCount = LearnSettingsDefaults.ContextWords; private const string EndOfScriptPhrase = "End of script."; private const string LoadLearnMessage = "Unable to load RSVP rehearsal right now."; private const string LoadLearnOperation = "Learn load"; @@ -17,6 +18,7 @@ public partial class LearnPage : IAsyncDisposable private const int MinimumWordDurationMilliseconds = 60; private const string NeutralEmotion = "neutral"; private const string ReadyWord = "Ready"; + private const int ReadyWordDurationMilliseconds = 240; private const int PreviewWordCount = 10; private const int RsvpMaxSpeed = 600; private const int RsvpMinSpeed = 100; @@ -39,7 +41,9 @@ public partial class LearnPage : IAsyncDisposable public string? ScriptId { get; set; } private CancellationTokenSource? _playbackCts; + private ElementReference _focusOrp; private ElementReference _focusRow; + private ElementReference _focusWord; private ElementReference _screenRoot; private string _nextPhrase = string.Empty; private string _progressFillWidth = "0%"; @@ -48,7 +52,7 @@ public partial class LearnPage : IAsyncDisposable private string _screenTitle = string.Empty; private int _contextWordCount = DefaultContextWordCount; private int _currentIndex; - private int _speed = 300; + private int _speed = LearnSettingsDefaults.WordsPerMinute; private bool _isPlaying; private bool _isLoopEnabled; private bool _loadState = true; @@ -97,7 +101,7 @@ await Diagnostics.RunAsync( if (_syncFocusLayoutAfterRender) { _syncFocusLayoutAfterRender = false; - await LearnRsvpLayoutInterop.SyncLayoutAsync(_focusRow); + await LearnRsvpLayoutInterop.SyncLayoutAsync(_focusRow, _focusWord, _focusOrp); } if (_startPlaybackAfterLayoutSync) @@ -125,7 +129,7 @@ private void PopulateLearnState() PlaybackEngine.WordsPerMinute = _speed; PlaybackEngine.LoadTimeline(processed); - _timeline = BuildTimeline(processed, _speed); + _timeline = BuildTimeline(processed); _currentIndex = 0; _isPlaying = learnSettings.AutoPlay; _isLoopEnabled = learnSettings.LoopPlayback; @@ -179,20 +183,24 @@ private void UpdateDisplayedState() _currentWordLeading = focusWord.Leading; _currentWordOrp = focusWord.Orp; _currentWordTrailing = focusWord.Trailing; - _leftContextWords = BuildDisplayContextWords( + _leftContextWords = BuildDisplayContextWindowWords( _timeline, - Math.Max(sentenceRange.StartIndex, _currentIndex - _contextWordCount), - _currentIndex); - _rightContextWords = BuildDisplayContextWords( + sentenceRange.StartIndex, + _currentIndex, + _contextWordCount, + takeTrailingWords: true); + _rightContextWords = BuildDisplayContextWindowWords( _timeline, _currentIndex + 1, - Math.Min(sentenceRange.EndIndex + 1, _currentIndex + 1 + _contextWordCount)); + sentenceRange.EndIndex + 1, + _contextWordCount, + takeTrailingWords: false); var rawPreviewText = string.IsNullOrWhiteSpace(entry.NextPhrase) ? ResolveFallbackNextPhrase(_timeline, _currentIndex) : entry.NextPhrase; _nextPhrase = BuildDisplayPreviewText(rawPreviewText); _progressFillWidth = $"{((_currentIndex + 1) * 100d / _timeline.Count):0.##}%"; - _progressLabel = BuildProgressLabel(_timeline, _currentIndex, _speed); + _progressLabel = BuildProgressLabel(_timeline, _currentIndex); _syncFocusLayoutAfterRender = true; } @@ -214,19 +222,4 @@ private async Task EnsureSessionLoadedAsync() private void UpdateShellState() => Shell.ShowLearn(_screenTitle, _screenSubtitle, BuildWpmLabel(_speed), SessionService.State.ScriptId); - - private int GetScaledDuration(int sourceMilliseconds, int baseWpm, bool allowZero = false) - { - if (sourceMilliseconds <= 0) - { - return allowZero ? 0 : MinimumWordDurationMilliseconds; - } - - var effectiveBaseWpm = baseWpm > 0 ? baseWpm : _speed; - var scaledDuration = sourceMilliseconds * (effectiveBaseWpm / (double)Math.Max(_speed, 1)); - var roundedDuration = (int)Math.Round(scaledDuration); - return allowZero - ? Math.Max(0, roundedDuration) - : Math.Max(MinimumWordDurationMilliseconds, roundedDuration); - } } diff --git a/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutContract.cs b/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutContract.cs new file mode 100644 index 0000000..9d870fa --- /dev/null +++ b/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutContract.cs @@ -0,0 +1,8 @@ +namespace PrompterOne.Shared.Services; + +internal static class LearnRsvpLayoutContract +{ + public const string FocusLeftExtentCssCustomProperty = "--rsvp-focus-left-extent"; + public const string FocusRightExtentCssCustomProperty = "--rsvp-focus-right-extent"; + public const string FontSyncReadyAttributeName = "data-rsvp-layout-font-sync-ready"; +} diff --git a/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutInterop.cs b/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutInterop.cs index bb8d9d8..0be3082 100644 --- a/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutInterop.cs +++ b/src/PrompterOne.Shared/Learn/Services/LearnRsvpLayoutInterop.cs @@ -7,6 +7,13 @@ public sealed class LearnRsvpLayoutInterop(IJSRuntime jsRuntime) { private readonly IJSRuntime _jsRuntime = jsRuntime; - public ValueTask SyncLayoutAsync(ElementReference row) => - _jsRuntime.InvokeVoidAsync(LearnRsvpLayoutInteropMethodNames.SyncLayout, row); + public ValueTask SyncLayoutAsync(ElementReference row, ElementReference focusWord, ElementReference focusOrp) => + _jsRuntime.InvokeVoidAsync( + LearnRsvpLayoutInteropMethodNames.SyncLayout, + row, + focusWord, + focusOrp, + LearnRsvpLayoutContract.FocusLeftExtentCssCustomProperty, + LearnRsvpLayoutContract.FocusRightExtentCssCustomProperty, + LearnRsvpLayoutContract.FontSyncReadyAttributeName); } 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 25fdd35..23d2f98 100644 --- a/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs +++ b/src/PrompterOne.Shared/Media/Services/BrowserMediaDeviceService.cs @@ -4,7 +4,7 @@ namespace PrompterOne.Shared.Services; -public sealed class BrowserMediaDeviceService(IJSRuntime jsRuntime) : IMediaDeviceService +public sealed partial class BrowserMediaDeviceService(IJSRuntime jsRuntime) : IMediaDeviceService { private readonly IJSRuntime _jsRuntime = jsRuntime; @@ -16,7 +16,7 @@ public async Task> GetDevicesAsync(CancellationTo return devices.Select(device => new MediaDeviceInfo( device.DeviceId, - string.IsNullOrWhiteSpace(device.Label) ? "Unnamed device" : device.Label, + MediaDeviceLabelSanitizer.Sanitize(device.Label), device.Kind switch { "videoinput" => MediaDeviceKind.Camera, @@ -27,5 +27,5 @@ public async Task> GetDevicesAsync(CancellationTo device.IsDefault)).ToList(); } - private sealed record BrowserMediaDeviceDto(string DeviceId, string Label, string Kind, bool IsDefault); + 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/Services/StreamingPlatformPresentationCatalog.cs b/src/PrompterOne.Shared/Services/StreamingPlatformPresentationCatalog.cs new file mode 100644 index 0000000..fcaedfd --- /dev/null +++ b/src/PrompterOne.Shared/Services/StreamingPlatformPresentationCatalog.cs @@ -0,0 +1,48 @@ +using PrompterOne.Core.Models.Streaming; + +namespace PrompterOne.Shared.Services; + +public sealed record StreamingPlatformPresentation( + string SettingsAccountLabel, + string SettingsDescription, + string GoLivePlatformLabel, + string Tone, + string IconCssClass); + +public static class StreamingPlatformPresentationCatalog +{ + public static StreamingPlatformPresentation Get(StreamingPlatformKind kind) => + kind switch + { + StreamingPlatformKind.LiveKit => new( + SettingsAccountLabel: "LiveKit · Browser publish", + SettingsDescription: "Store LiveKit browser publishing settings here so Go Live stays operational-only.", + GoLivePlatformLabel: "Guest room", + Tone: "livekit", + IconCssClass: "set-dest-rtmp"), + StreamingPlatformKind.VdoNinja => new( + SettingsAccountLabel: "VDO.Ninja · Browser room", + SettingsDescription: "Keep VDO.Ninja room and publish URL setup in Settings and launch from Go Live only when ready.", + GoLivePlatformLabel: "Browser room", + Tone: "relay", + IconCssClass: "set-dest-rtmp"), + StreamingPlatformKind.Youtube => new( + SettingsAccountLabel: "YouTube Live · RTMP / RTMPS", + SettingsDescription: "Store YouTube RTMP credentials here and use Go Live only to arm and inspect the destination.", + GoLivePlatformLabel: "Relay target", + Tone: "youtube", + IconCssClass: "set-dest-yt"), + StreamingPlatformKind.Twitch => new( + SettingsAccountLabel: "Twitch · RTMP", + SettingsDescription: "Persist Twitch endpoint and key here so the runtime surface stays focused on program switching.", + GoLivePlatformLabel: "Relay target", + Tone: "twitch", + IconCssClass: "set-dest-tw"), + _ => new( + SettingsAccountLabel: "Custom RTMP · Private ingest", + SettingsDescription: "Use a private ingest endpoint or CDN and keep the credentials out of the live runtime surface.", + GoLivePlatformLabel: "Relay target", + Tone: "relay", + IconCssClass: "set-dest-rtmp") + }; +} diff --git a/src/PrompterOne.Shared/Services/StreamingPublishDescriptorResolver.cs b/src/PrompterOne.Shared/Services/StreamingPublishDescriptorResolver.cs new file mode 100644 index 0000000..ac9b633 --- /dev/null +++ b/src/PrompterOne.Shared/Services/StreamingPublishDescriptorResolver.cs @@ -0,0 +1,28 @@ +using PrompterOne.Core.Abstractions; +using PrompterOne.Core.Models.Streaming; + +namespace PrompterOne.Shared.Services; + +public sealed class StreamingPublishDescriptorResolver(IEnumerable providers) +{ + private readonly IReadOnlyDictionary _providers = providers + .GroupBy(provider => provider.Kind) + .ToDictionary(group => group.Key, group => group.First()); + + public StreamingPublishDescriptor Describe(StreamingProfile profile) + { + if (_providers.TryGetValue(profile.ProviderKind, out var provider)) + { + return provider.Describe(profile); + } + + return new StreamingPublishDescriptor( + ProviderId: profile.ProviderKind.ToString(), + ProviderKind: profile.ProviderKind, + DisplayName: profile.Name, + IsReady: false, + RequiresExternalRelay: false, + Summary: "No streaming provider is registered for this destination.", + Parameters: new Dictionary(StringComparer.Ordinal)); + } +} diff --git a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor index 5738fe4..0587faa 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsAiSection.razor @@ -1,11 +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.

@@ -28,11 +30,8 @@
- +
@@ -79,12 +78,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 string? GetOptionTestId(string optionValue) => + TestId is null ? null : UiTestIds.Settings.SelectOption(TestId, optionValue); + + 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/SettingsStreamingExternalDestinationCard.razor b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingExternalDestinationCard.razor new file mode 100644 index 0000000..370524b --- /dev/null +++ b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingExternalDestinationCard.razor @@ -0,0 +1,221 @@ +@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.Services +@using PrompterOne.Shared.Settings.Models +@inject StreamingPublishDescriptorResolver DescriptorResolver + + + @switch (Destination.PlatformKind) + { + case StreamingPlatformKind.LiveKit: + + + + + + break; + case StreamingPlatformKind.VdoNinja: + + + + + break; + case StreamingPlatformKind.Youtube: + + + + + break; + case StreamingPlatformKind.Twitch: + + + + + + break; + default: + + + + + break; + } + + + + @switch (Destination.PlatformKind) + { + case StreamingPlatformKind.LiveKit: +
+ + +
+
+ + +
+
+ + +
+ break; + case StreamingPlatformKind.VdoNinja: +
+ + +
+
+ + +
+ break; + case StreamingPlatformKind.CustomRtmp: +
+ + +
+ goto default; + default: +
+ + +
+
+ + +
+ break; + } + +
+ +
+
+ +@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 26fb9ce..e406e1c 100644 --- a/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor +++ b/src/PrompterOne.Shared/Settings/Components/SettingsStreamingPanel.razor @@ -1,37 +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

- - + +
- - + +
- +
- +
@@ -47,7 +38,7 @@
- +
@@ -58,409 +49,66 @@
-

Destinations

+

@SettingsStreamingText.DestinationsTitle

- - - -
- -
- -
- - - @BuildLocalSummary(GoLiveTargetCatalog.TargetIds.Obs) - -
-
- - -
- - - @BuildLocalSummary(GoLiveTargetCatalog.TargetIds.Ndi) - -
-
- - -
- - - @BuildLocalSummary(GoLiveTargetCatalog.TargetIds.Recording) - -
-
- - - - - - - - -
- - -
-
- - -
-
- - -
-
- - - - - - - -
- - -
-
- - -
-
- - - - - - - -
- - -
-
- - -
-
- - - - - - - - -
- - -
-
- - -
-
- - - - - - - -
- - -
-
- - -
-
- - -
-
-
- -@code { - 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) - { - if (!isEnabled) - { - return false; - } - - if (GetSelectedSourceIds(targetId).Count == 0) - { - return false; - } - - return requiredValues.All(value => !string.IsNullOrWhiteSpace(value)); - } - - private string BuildLocalSummary(string targetId) + @foreach (var platform in StreamingPlatformCatalog.All) { - 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/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/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 93555da..9129478 100644 --- a/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor +++ b/src/PrompterOne.Shared/Settings/Pages/SettingsPage.razor @@ -5,7 +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 @@ -27,11 +30,11 @@
- @camera.Label + @MediaDeviceLabelSanitizer.Sanitize(camera.Label) @BuildCameraMeta(camera, isPrimary)
@@ -150,15 +153,10 @@
- +
@@ -336,7 +334,7 @@
- @microphone.Label + @MediaDeviceLabelSanitizer.Sanitize(microphone.Label) @BuildMicrophoneMeta(microphone)
@@ -432,33 +430,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" />
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/Settings/Services/StreamingSettingsNormalizer.cs b/src/PrompterOne.Shared/Settings/Services/StreamingSettingsNormalizer.cs index be02521..5175d02 100644 --- a/src/PrompterOne.Shared/Settings/Services/StreamingSettingsNormalizer.cs +++ b/src/PrompterOne.Shared/Settings/Services/StreamingSettingsNormalizer.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; @@ -8,54 +9,286 @@ internal static class StreamingSettingsNormalizer { public static StudioSettings Normalize(StudioSettings settings, IReadOnlyList 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.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..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"; @@ -161,6 +162,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 +175,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 +193,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 +222,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 +247,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 +270,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)) { @@ -330,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 @@ -346,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 } }; } @@ -382,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 9b96849..a302c18 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs @@ -31,27 +31,11 @@ private sealed record ReaderCardViewModel( string WidthPercentString, string EdgeColor, IReadOnlyList Chunks, - string TestId) - { - public static ReaderCardViewModel Empty { get; } = new( - SectionName: "Introduction", - DisplayName: "Opening Block", - EmotionKey: "warm", - EmotionLabel: "Warm", - BackgroundClass: "warm", - AccentColor: "#E97F00", - TargetWpm: 140, - WordCount: 1, - DurationMilliseconds: 1000, - WidthPercentString: "100%", - EdgeColor: "rgba(233, 127, 0, 0.35)", - Chunks: [new ReaderGroupViewModel([new ReaderWordViewModel("Ready", "tps-warm", 600)])], - TestId: UiTestIds.Teleprompter.Card(999)); - } + string TestId); 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..b7322d4 100644 --- a/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs +++ b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderRendering.cs @@ -12,8 +12,12 @@ 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"; private const string ReaderHorizontalGuideCssClass = "rd-guide-h"; private const string ReaderVerticalGuideCssClass = "rd-guide-v"; private const string ReaderVerticalGuideLeftCssClass = "rd-guide-v-l"; @@ -26,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"; @@ -68,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); @@ -118,9 +128,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 +141,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 b/src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.razor index 5cf659d..999108d 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 - - - -
-
+