From 5a603c7553829ff89b723f10809de2aa63053272 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Wed, 1 Apr 2026 00:19:47 +0200 Subject: [PATCH 1/2] google analytics --- src/PrompterLive.App/wwwroot/index.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/PrompterLive.App/wwwroot/index.html b/src/PrompterLive.App/wwwroot/index.html index 341a4c1..0935070 100644 --- a/src/PrompterLive.App/wwwroot/index.html +++ b/src/PrompterLive.App/wwwroot/index.html @@ -59,5 +59,14 @@

Prompter.live hit a shell error

} }); + + + From 26160c0bb7f9725b5f12749567efba4aa53d2206 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Wed, 1 Apr 2026 01:08:31 +0200 Subject: [PATCH 2/2] Fix about attribution and teleprompter TPS fidelity --- AGENTS.md | 4 + about-teleprompter-fidelity.plan.md | 154 +++++++++++++ docs/Features/AppVersioningAndGitHubPages.md | 4 +- docs/Features/ReaderRuntime.md | 9 + new-design/settings.html | 80 +++---- new-design/styles-settings.css | 12 + new-design/styles-teleprompter.css | 96 +++++--- .../Tps/Services/ScriptCompiler.cs | 23 ++ .../Contracts/AboutLinks.cs | 13 ++ .../Contracts/UiTestIds.cs | 10 + .../Components/SettingsAboutSection.razor | 71 +++--- .../Components/SettingsAboutSection.razor.cs | 61 ++++- .../Pages/TeleprompterPage.ReaderAlignment.cs | 63 +++--- .../Pages/TeleprompterPage.ReaderContent.cs | 49 +++-- .../Pages/TeleprompterPage.ReaderModels.cs | 10 +- .../Pages/TeleprompterPage.ReaderPlayback.cs | 3 + .../TeleprompterPage.ReaderWordStyling.cs | 91 ++++++++ .../Teleprompter/Pages/TeleprompterPage.razor | 10 +- .../Services/TeleprompterReaderInterop.cs | 6 +- .../design/modules/reader/00-shell.css | 9 +- .../modules/reader/10-reading-states.css | 35 ++- .../design/modules/reader/20-controls.css | 55 +++-- .../design/modules/settings/20-reference.css | 26 +++ .../teleprompter/teleprompter-reader.js | 21 +- .../Settings/SettingsInteractionTests.cs | 27 ++- .../Support/AppTestData.cs | 1 + .../Teleprompter/TeleprompterFidelityTests.cs | 54 +++++ .../Support/BrowserTestConstants.Scenarios.cs | 8 + .../BrowserTestConstants.ScreenFlows.cs | 12 + .../Teleprompter/TeleprompterFullFlowTests.cs | 208 ++++++++++++++++++ .../TeleprompterSettingsFlowTests.cs | 38 +++- .../Tps/TpsRoundTripTests.cs | 29 +++ 32 files changed, 1083 insertions(+), 209 deletions(-) create mode 100644 about-teleprompter-fidelity.plan.md create mode 100644 src/PrompterLive.Shared/Contracts/AboutLinks.cs create mode 100644 src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs create mode 100644 tests/PrompterLive.App.UITests/Teleprompter/TeleprompterFullFlowTests.cs diff --git a/AGENTS.md b/AGENTS.md index fd8f5bd..5088d03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -290,6 +290,7 @@ Repo-specific design rules: - Treat every file under `new-design/` as a static design/prototype reference only. Production UI must be implemented as Blazor components in `src/PrompterLive.Shared`; do not ship raw `new-design` HTML as runtime UI. - Do not re-invent the UI when the answer should be “port the markup and classes from `new-design`”. - For parity tasks, port the full routed screen from its matching `new-design/*.html` reference, not just isolated high-signal blocks. Settings, Editor, Learn, Teleprompter, and Go Live must match the reference screen in layout and intended interaction while staying Blazor/C# owned. +- About content must stay factual and current: do not invent team members or contributor names; use Managed Code attribution and official company links only. - Do not introduce a server host for the app runtime. - Preserve stable `data-testid` selectors on core flows because the Playwright suite depends on them. - Keep UI routes in shared route constants and keep `data-testid` names in shared UI contract constants. @@ -320,6 +321,7 @@ Repo-specific design rules: - Never introduce a non-SOLID design unless the exception is explicitly documented under `exception_policy`. - Never force-push to `main`. - Never approve or merge on behalf of a human maintainer. +- When the task explicitly needs delivery, the agent may commit, push to `main` or a feature branch, open a PR, and merge it after the required tests and validation commands pass. ### Boundaries @@ -352,7 +354,9 @@ Ask first: - brittle selectors without `data-testid` - mixed-language root README or public entry docs; keep them English-only unless the user explicitly asks otherwise - design drift from `new-design` +- 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 - editor keystroke paths that persist, compile, or rebuild shared session state; keep plain typing in memory and move heavier local sync to debounce or autosave - murky JavaScript or interop layers that keep product UI behavior in JS when Blazor can own it cleanly - runtime dependencies fetched from random external sources instead of vendored release artifacts diff --git a/about-teleprompter-fidelity.plan.md b/about-teleprompter-fidelity.plan.md new file mode 100644 index 0000000..3f35ccf --- /dev/null +++ b/about-teleprompter-fidelity.plan.md @@ -0,0 +1,154 @@ +# About And Teleprompter Fidelity Plan + +## Task Goal + +Remove invented hardcoded About content, replace it with factual Managed Code attribution and official links, then bring the teleprompter reading experience in line with `new-design/teleprompter.html` and the TPS specification so playback is visually smooth, timing-aware, and fully covered by automated tests. + +## Scope + +### In Scope + +- Update the Settings About section so it no longer shows invented people and instead shows factual Managed Code ownership plus official links, including GitHub. +- Tighten teleprompter rendering and playback so active reading does not visibly jump while words/cards advance. +- Make teleprompter controls materially more visible during use while staying aligned with the design direction. +- Extend teleprompter rendering to reflect the TPS cues already produced by the compiler, including color, emotion, highlight, pronunciation cues, and speed-sensitive visual spacing. +- Add or update bUnit and Playwright coverage for About and teleprompter fidelity, including at least one end-to-end teleprompter scenario. +- Run build and relevant verification, then format, commit, and push. + +### Out Of Scope + +- Changing TPS parsing rules in `PrompterLive.Core` unless a concrete renderer gap requires a narrowly scoped compatibility fix. +- Redesigning routes or app shell behavior outside Settings/About and Teleprompter. +- Adding a backend or changing runtime hosting shape. + +## Constraints And Risks + +- `new-design/teleprompter.html` remains the visual reference; parity work should preserve its structure and interaction tone. +- Browser UI tests are the primary acceptance gate; teleprompter changes are not done until real-browser checks pass. +- The UI suite must run in a single `dotnet test` process and not overlap with other build/test commands. +- `About` must stay factual: no invented team members or stale attribution. +- Teleprompter smoothing must not create delayed input, unreadable word spacing, or layout churn from width-changing state changes. +- Files should stay within maintainability limits; if teleprompter logic needs more space, split it into focused partials/helpers instead of growing a large file further. + +## Testing Methodology + +- Use bUnit to verify About content contracts and teleprompter rendered markup/state mappings for TPS styling classes and metadata-driven output. +- Use Playwright UI tests to verify real browser playback, focal alignment, control visibility, timing continuity, and a full teleprompter scenario with screenshots under `output/playwright/`. +- Validate both static fidelity and dynamic behavior: + - About shows factual Managed Code attribution and official links. + - Teleprompter words preserve TPS-driven styling and pacing hints. + - Playback advances without vertical jump artifacts on active text. + - Card transitions stay smooth and time/progress continue advancing. + - Controls remain visibly usable against live camera/gradient backgrounds. +- Quality bar: + - No teleprompter regression in relevant bUnit coverage. + - Relevant Playwright teleprompter flows green. + - Repo build green under `-warnaserror`. + +## Ordered Plan + +- [x] Step 1. Establish baseline context and failures. + - Read the exact About and teleprompter implementation/test files that own this work. + - Run the relevant baseline commands in order: + - `dotnet build /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx -warnaserror` + - `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj` + - `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.UITests/PrompterLive.App.UITests.csproj --no-build` + - Verification before moving on: + - Record every failing test and symptom below. + - Confirm whether teleprompter failures reproduce before code changes. + +- [x] Step 2. Replace hardcoded About content with factual Managed Code metadata. + - Update `src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor` and its code-behind to remove invented people and add factual company attribution plus official links, including GitHub. + - Keep the section visually aligned with Settings design patterns and preserve existing test ids or add stable new ones if needed. + - Verification before moving on: + - Add/update bUnit assertions in `tests/PrompterLive.App.Tests/Settings/SettingsInteractionTests.cs`. + - Confirm no stale invented names remain via targeted search. + +- [x] Step 3. Expand teleprompter word rendering to honor TPS visual cues. + - Update teleprompter reader models/rendering so words expose the cues already emitted by `ScriptCompiler`, including stronger distinctions for emphasis/highlight, pronunciation/tooltips, emotion/color mappings, and speed-sensitive spacing. + - Keep speed-based letter spacing bounded so words never become mush or split into visually disconnected letters. + - Verification before moving on: + - Add/update bUnit tests under `tests/PrompterLive.App.Tests/Teleprompter/`. + - Use TPS-backed sample scripts to prove slow/fast/xslow/xfast, highlight, pronunciation, and emotion styling render as expected. + +- [x] Step 4. Remove teleprompter playback jumpiness and align transitions with the design. + - Refine reader alignment/transition logic and any supporting browser interop so active-word tracking and card transitions stay smooth during playback. + - Match the intended movement profile from `new-design/teleprompter.html` while avoiding text jumps during per-word advancement. + - Verification before moving on: + - Add/update Playwright assertions for continuity and alignment. + - Capture a teleprompter scenario screenshot artifact under `output/playwright/`. + +- [x] Step 5. Make teleprompter controls visibly usable. + - Update the relevant reader CSS modules so sliders, edge info, and control bar stay visible enough against the background instead of fading into near-invisibility. + - Keep the visual language aligned with the design reference while improving usability. + - Verification before moving on: + - Add/update browser checks that confirm control opacity/visibility at runtime. + +- [x] Step 6. Add a full teleprompter browser scenario. + - Add or extend a real Playwright scenario that opens a TPS-backed teleprompter script, starts playback, verifies styling/timing/progress/controls, and saves screenshots. + - Verification before moving on: + - Scenario passes in the browser suite. + - Screenshot artifacts are written under `output/playwright/`. + +- [x] Step 7. Run final validation and ship. + - Run the required verification in order: + - `dotnet build /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx -warnaserror` + - `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj` + - `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.UITests/PrompterLive.App.UITests.csproj --no-build` + - `dotnet test /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx` + - `dotnet format /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx` + - If green, commit with a focused message and push the current branch. + - Verification before moving on: + - All planned checklist items are complete. + - Working tree is clean except for intentional artifacts, if any. + +## Baseline Failures + +- [x] No pre-existing baseline failures in the relevant build, bUnit, or UI suites. + - Build: `dotnet build /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx -warnaserror` passed. + - bUnit: `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj` passed with `94/94`. + - UI: `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.UITests/PrompterLive.App.UITests.csproj --no-build` passed with `75/75`. + - Root-cause note: current repo baseline is green; this task will add targeted regression coverage for the requested About and teleprompter behavior. + - Fix status: no inherited failures to clear before implementation. + +## Intended Fix Tracking + +- [x] About invented team content removed and replaced with factual Managed Code attribution. +- [x] Teleprompter TPS styling parity expanded beyond the current reduced class mapping. +- [x] Teleprompter playback jumpiness eliminated or reduced to non-visible smooth motion. +- [x] Teleprompter controls made clearly visible during live use. +- [x] Full teleprompter browser scenario added or extended with screenshot artifacts. + +## Final Validation Skills + +- `dotnet` + - Reason: enforce repo-compatible build, test, and format commands for this Blazor/.NET solution. + - Expected outcome: green build/test/format evidence aligned with `AGENTS.md`. + +- `playwright` + - Reason: validate teleprompter behavior in a real browser and capture screenshot artifacts for the changed flow. + - Expected outcome: passing teleprompter scenario coverage with browser-realistic evidence. + +## Final Validation Results + +- `dotnet build /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx -warnaserror` + - Result: passed after implementation and again after formatting. +- `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.Core.Tests/PrompterLive.Core.Tests.csproj` + - Result: passed with `34/34`. +- `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj` + - Result: passed with `95/95`. +- `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.UITests/PrompterLive.App.UITests.csproj --no-build` + - Result: passed with `76/76`. +- `dotnet test /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx` + - Result: passed for the full solution. +- `dotnet test /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx --collect:"XPlat Code Coverage"` + - Result: passed for the full solution with coverage artifacts emitted for Core, App, and UI suites. +- `dotnet format /Users/ksemenenko/Developer/PrompterLive/PrompterLive.slnx` + - Result: passed. + +## Notes + +- `About` now uses only Managed Code and PrompterLive official links and removes invented roster content from both production UI and the design reference. +- Teleprompter playback now pre-centers upcoming content before card activation to avoid visible jump on live reading transitions. +- TPS shorthand inline speed tags like `[180WPM]...[/180WPM]` are now preserved by the compiler so reader timing matches TPS input. +- End-to-end browser evidence includes the teleprompter full-flow screenshots under `output/playwright/teleprompter-product-launch/`. diff --git a/docs/Features/AppVersioningAndGitHubPages.md b/docs/Features/AppVersioningAndGitHubPages.md index a564bf5..12ce57d 100644 --- a/docs/Features/AppVersioningAndGitHubPages.md +++ b/docs/Features/AppVersioningAndGitHubPages.md @@ -9,6 +9,7 @@ This flow keeps the version number automated: - local builds default to `0.1.0` - release builds derive `0.1.` from the active release workflow run - the About screen reads the compiled assembly metadata instead of hardcoded copy +- the About screen links only to official Managed Code and `managedcode/PrompterLive` resources; it must never invent a team roster ## Version And Deploy Flow @@ -44,7 +45,7 @@ flowchart LR - `PrompterLiveBuildNumber` comes from `GITHUB_RUN_NUMBER` when CI provides it, or falls back to `0` locally. - `.github/workflows/deploy-github-pages.yml` resolves the release version from `VersionPrefix`, so the release tag and the compiled app version stay aligned. - `Program.cs` creates `IAppVersionProvider` from the compiled `PrompterLive.App` assembly metadata. -- `SettingsAboutSection` renders that provider value in the About card subtitle. +- `SettingsAboutSection` renders that provider value in the About card subtitle and pairs it with official Managed Code, GitHub, releases, and issues links. ## GitHub Pages Rules @@ -65,6 +66,7 @@ flowchart LR - `.github/workflows/pr-validation.yml` runs `dotnet test tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj --no-build` - `.github/workflows/pr-validation.yml` runs `dotnet test tests/PrompterLive.App.UITests/PrompterLive.App.UITests.csproj --no-build` - `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj --filter "FullyQualifiedName~SettingsInteractionTests.AboutSection_RendersInjectedAppVersionMetadata"` +- `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.Tests/PrompterLive.App.Tests.csproj --filter "FullyQualifiedName~SettingsInteractionTests.AboutSection_RendersInjectedAppVersionMetadata_AndOfficialManagedCodeLinks"` - `dotnet test /Users/ksemenenko/Developer/PrompterLive/tests/PrompterLive.App.UITests/PrompterLive.App.UITests.csproj --filter "FullyQualifiedName~TeleprompterSettingsFlowTests.TeleprompterAndSettingsScreens_RespondToCoreControls"` - `.github/workflows/deploy-github-pages.yml` publish step passes `-p:PrompterLiveBuildNumber=${{ github.run_number }}` - `.github/workflows/deploy-github-pages.yml` publishes both the GitHub Release asset and the GitHub Pages artifact from the same release build output diff --git a/docs/Features/ReaderRuntime.md b/docs/Features/ReaderRuntime.md index 7dd3a06..5e8d3d5 100644 --- a/docs/Features/ReaderRuntime.md +++ b/docs/Features/ReaderRuntime.md @@ -11,6 +11,9 @@ The important contracts are: - RSVP keeps a five-word context rail on each side, matching `new-design/app.js`. - Teleprompter camera stays behind the text as one background layer. - Teleprompter word groups stay short enough to avoid run-on lines. +- Teleprompter preserves TPS word presentation details such as pronunciation guides, inline colors, emotion styling, and speed-derived spacing/timing. +- Teleprompter pre-centers the next card before it slides in, so block transitions do not jump at the focal line. +- Teleprompter controls stay readable at rest; they must not fade until they become unusable. ## Flow @@ -38,11 +41,17 @@ flowchart LR - `teleprompter` selects one primary camera device for `#rd-camera`. - `teleprompter` does not render overlay camera elements such as `#rd-camera-overlay-*`. - `teleprompter` groups words by pauses, sentence endings, clause endings, and short phrase limits. +- `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` keeps TPS inline colors visible even when a phrase group is active or the active word is highlighted. ## Verification - bUnit verifies teleprompter background-camera markup and readable phrase groups. +- bUnit verifies product-launch TPS modifiers survive into teleprompter word markup, timing, and pronunciation metadata. - Core tests verify TPS scripts generate RSVP phrase groups. +- Core tests verify shorthand inline WPM scopes such as `[180WPM]...[/180WPM]` survive nested tags. - Playwright verifies ORP centering and the `security-incident` phrase-aware flow in `learn`. - 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. diff --git a/new-design/settings.html b/new-design/settings.html index a3a54e1..c4de990 100644 --- a/new-design/settings.html +++ b/new-design/settings.html @@ -1240,41 +1240,42 @@

About

- +
- +
- Our Team - + Managed Code +
@@ -1362,11 +1368,11 @@

About

- Made with care in Ukraine 🇺🇦 · © 2026 Prompter.live + Built and maintained by Managed Code.
- \ No newline at end of file + diff --git a/new-design/styles-settings.css b/new-design/styles-settings.css index b63d893..d01c752 100644 --- a/new-design/styles-settings.css +++ b/new-design/styles-settings.css @@ -1344,11 +1344,23 @@ text-decoration: none; display: block; cursor: pointer; + color: inherit; } .set-about-link:hover .set-dest-header { background: none; } +.set-about-link-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.set-about-link-meta { + color: var(--t4); + font-size: 11px; +} + .set-about-footer { text-align: center; font-size: 12px; diff --git a/new-design/styles-teleprompter.css b/new-design/styles-teleprompter.css index fc2ed72..1f121aa 100644 --- a/new-design/styles-teleprompter.css +++ b/new-design/styles-teleprompter.css @@ -219,10 +219,10 @@ .tps-professional { color: #5B7FFF; } /* ── TPS Speed Indicators (visual cue for speed tags) ── */ -.tps-xslow { letter-spacing: 1.5px; } -.tps-slow { letter-spacing: 0.5px; } -.tps-fast { letter-spacing: -0.3px; } -.tps-xfast { letter-spacing: -0.5px; opacity: .7; } +.tps-xslow { letter-spacing: var(--tps-word-letter-spacing, 0.055em); } +.tps-slow { letter-spacing: var(--tps-word-letter-spacing, 0.028em); } +.tps-fast { letter-spacing: var(--tps-word-letter-spacing, -0.015em); } +.tps-xfast { letter-spacing: var(--tps-word-letter-spacing, -0.028em); opacity: .78; } /* ── TPS Pronunciation guide (subtle tooltip-like) ── */ .tps-phonetic { @@ -453,6 +453,23 @@ span.rd-pause.rd-pause-long { .rd-g-active > .rd-w.tps-orange:not(.rd-now):not(.rd-read) { color: rgba(255,169,77,.75); } .rd-g-active > .rd-w.tps-purple:not(.rd-now):not(.rd-read) { color: rgba(204,93,232,.75); } .rd-g-active > .rd-w.tps-cyan:not(.rd-now):not(.rd-read) { color: rgba(102,217,232,.75); } +.rd-g-active > .rd-w.tps-magenta:not(.rd-now):not(.rd-read) { color: rgba(247,131,172,.75); } +.rd-g-active > .rd-w.tps-pink:not(.rd-now):not(.rd-read) { color: rgba(250,162,193,.75); } +.rd-g-active > .rd-w.tps-teal:not(.rd-now):not(.rd-read) { color: rgba(56,217,169,.75); } +.rd-g-active > .rd-w.tps-white:not(.rd-now):not(.rd-read) { color: rgba(248,249,250,.78); } +.rd-g-active > .rd-w.tps-gray:not(.rd-now):not(.rd-read) { color: rgba(173,181,189,.72); } +.rd-g-active > .rd-w.tps-warm:not(.rd-now):not(.rd-read) { color: rgba(255,169,77,.75); } +.rd-g-active > .rd-w.tps-concerned:not(.rd-now):not(.rd-read) { color: rgba(255,107,107,.75); } +.rd-g-active > .rd-w.tps-focused:not(.rd-now):not(.rd-read) { color: rgba(81,207,102,.75); } +.rd-g-active > .rd-w.tps-motivational:not(.rd-now):not(.rd-read) { color: rgba(204,93,232,.75); } +.rd-g-active > .rd-w.tps-neutral:not(.rd-now):not(.rd-read) { color: rgba(116,192,252,.75); } +.rd-g-active > .rd-w.tps-urgent:not(.rd-now):not(.rd-read) { color: rgba(255,68,68,.82); } +.rd-g-active > .rd-w.tps-happy:not(.rd-now):not(.rd-read) { color: rgba(255,224,102,.75); } +.rd-g-active > .rd-w.tps-excited:not(.rd-now):not(.rd-read) { color: rgba(250,162,193,.75); } +.rd-g-active > .rd-w.tps-sad:not(.rd-now):not(.rd-read) { color: rgba(123,104,238,.75); } +.rd-g-active > .rd-w.tps-calm:not(.rd-now):not(.rd-read) { color: rgba(56,217,169,.75); } +.rd-g-active > .rd-w.tps-energetic:not(.rd-now):not(.rd-read) { color: rgba(255,99,71,.78); } +.rd-g-active > .rd-w.tps-professional:not(.rd-now):not(.rd-read) { color: rgba(91,127,255,.78); } .rd-g-active > .rd-w.tps-emphasis:not(.rd-now):not(.rd-read) { color: rgba(232,213,176,.80); } .rd-g-active > .rd-w.tps-highlight:not(.rd-now):not(.rd-read) { background: rgba(255,224,102,.18); } @@ -495,6 +512,23 @@ span.rd-pause.rd-pause-long { .rd-w.rd-now.tps-orange { color: #FFA94D; text-shadow: 0 0 40px rgba(255,169,77,.3); } .rd-w.rd-now.tps-purple { color: #CC5DE8; text-shadow: 0 0 40px rgba(204,93,232,.3); } .rd-w.rd-now.tps-cyan { color: #66D9E8; text-shadow: 0 0 40px rgba(102,217,232,.3); } +.rd-w.rd-now.tps-magenta { color: #F783AC; text-shadow: 0 0 40px rgba(247,131,172,.3); } +.rd-w.rd-now.tps-pink { color: #FAA2C1; text-shadow: 0 0 40px rgba(250,162,193,.3); } +.rd-w.rd-now.tps-teal { color: #38D9A9; text-shadow: 0 0 40px rgba(56,217,169,.3); } +.rd-w.rd-now.tps-white { color: #F8F9FA; text-shadow: 0 0 40px rgba(248,249,250,.25); } +.rd-w.rd-now.tps-gray { color: #ADB5BD; text-shadow: 0 0 40px rgba(173,181,189,.25); } +.rd-w.rd-now.tps-warm { color: #FFA94D; text-shadow: 0 0 40px rgba(255,169,77,.3); } +.rd-w.rd-now.tps-concerned { color: #FF6B6B; text-shadow: 0 0 40px rgba(255,107,107,.3); } +.rd-w.rd-now.tps-focused { color: #51CF66; text-shadow: 0 0 40px rgba(81,207,102,.3); } +.rd-w.rd-now.tps-motivational { color: #CC5DE8; text-shadow: 0 0 40px rgba(204,93,232,.3); } +.rd-w.rd-now.tps-neutral { color: #74C0FC; text-shadow: 0 0 40px rgba(116,192,252,.3); } +.rd-w.rd-now.tps-urgent { color: #FF4444; text-shadow: 0 0 40px rgba(255,68,68,.32); } +.rd-w.rd-now.tps-happy { color: #FFE066; text-shadow: 0 0 40px rgba(255,224,102,.3); } +.rd-w.rd-now.tps-excited { color: #FAA2C1; text-shadow: 0 0 40px rgba(250,162,193,.3); } +.rd-w.rd-now.tps-sad { color: #7B68EE; text-shadow: 0 0 40px rgba(123,104,238,.3); } +.rd-w.rd-now.tps-calm { color: #38D9A9; text-shadow: 0 0 40px rgba(56,217,169,.3); } +.rd-w.rd-now.tps-energetic { color: #FF6347; text-shadow: 0 0 40px rgba(255,99,71,.3); } +.rd-w.rd-now.tps-professional { color: #5B7FFF; text-shadow: 0 0 40px rgba(91,127,255,.3); } /* Active word with emphasis/strong */ .rd-w.rd-now.tps-emphasis, @@ -636,10 +670,10 @@ span.rd-pause.rd-pause-long { justify-content: space-between; z-index: 10; pointer-events: none; - opacity: .15; + opacity: .58; transition: opacity .3s; } -.rd-edge-info:hover { opacity: .4; pointer-events: auto; } +.rd-edge-info:hover { opacity: .82; pointer-events: auto; } .rd-edge-section { font-size: 10px; font-weight: 600; @@ -651,7 +685,7 @@ span.rd-pause.rd-pause-long { .rd-time { font-size: 11px; font-family: var(--mono); - color: rgba(232,213,176,.3); + color: rgba(232,213,176,.55); white-space: nowrap; } @@ -687,15 +721,15 @@ span.rd-pause.rd-pause-long { align-items: center; gap: 20px; padding: 12px 6px; - background: rgba(20,18,12,.5); - backdrop-filter: blur(12px); - border: 1px solid var(--gold-08); + background: rgba(20,18,12,.74); + backdrop-filter: blur(18px); + border: 1px solid var(--gold-14); border-radius: 12px; - opacity: .2; + opacity: .66; transition: opacity .4s; } .rd-sliders:hover { - opacity: .75; + opacity: .96; } .rd-slider-group { display: flex; @@ -704,13 +738,13 @@ span.rd-pause.rd-pause-long { gap: 6px; } .rd-slider-icon { - color: rgba(232,213,176,.5); + color: rgba(232,213,176,.72); flex-shrink: 0; } .rd-slider-val { font-size: 9px; font-family: var(--mono); - color: rgba(232,213,176,.35); + color: rgba(232,213,176,.62); letter-spacing: 0.5px; white-space: nowrap; } @@ -729,7 +763,7 @@ span.rd-pause.rd-pause-long { .rd-vslider::-webkit-slider-runnable-track { width: 3px; height: 100%; - background: rgba(232,213,176,.10); + background: rgba(232,213,176,.22); border-radius: 2px; } .rd-vslider::-webkit-slider-thumb { @@ -737,19 +771,19 @@ span.rd-pause.rd-pause-long { width: 14px; height: 14px; border-radius: 50%; - background: rgba(232,213,176,.45); - border: 2px solid rgba(232,213,176,.15); + background: rgba(232,213,176,.78); + border: 2px solid rgba(12,10,6,.38); margin-left: -5.5px; cursor: grab; transition: background .2s; } .rd-vslider::-webkit-slider-thumb:hover { - background: rgba(232,213,176,.7); + background: rgba(232,213,176,.94); } .rd-vslider::-moz-range-track { width: 3px; height: 100%; - background: rgba(232,213,176,.10); + background: rgba(232,213,176,.22); border-radius: 2px; border: none; } @@ -757,8 +791,8 @@ span.rd-pause.rd-pause-long { width: 14px; height: 14px; border-radius: 50%; - background: rgba(232,213,176,.45); - border: 2px solid rgba(232,213,176,.15); + background: rgba(232,213,176,.78); + border: 2px solid rgba(12,10,6,.38); cursor: grab; } /* Countdown uses no CSS animations — digits appear/disappear instantly */ @@ -774,11 +808,11 @@ span.rd-pause.rd-pause-long { align-items: center; gap: 8px; padding: 6px 12px; - background: rgba(20,18,12,.75); - backdrop-filter: blur(16px); - border: 1px solid var(--gold-08); + background: rgba(20,18,12,.84); + backdrop-filter: blur(18px); + border: 1px solid var(--gold-14); border-radius: 16px; - opacity: .85; + opacity: .97; transition: opacity .3s; } .rd-controls:hover { opacity: 1; } @@ -800,7 +834,7 @@ span.rd-pause.rd-pause-long { height: 36px; border-radius: 10px; background: transparent; - color: rgba(232,213,176,.4); + color: rgba(232,213,176,.74); display: flex; align-items: center; justify-content: center; @@ -809,7 +843,7 @@ span.rd-pause.rd-pause-long { transition: all .2s; } .rd-ctrl-btn:hover { - background: var(--gold-08); + background: var(--gold-12); color: #E8D5B0; } .rd-ctrl-btn.active { @@ -819,23 +853,23 @@ span.rd-pause.rd-pause-long { .rd-ctrl-sm { width: 28px; height: 28px; - opacity: .5; + opacity: .82; } .rd-ctrl-sm:hover { opacity: 1; } .rd-ctrl-play { width: 44px; height: 44px; border-radius: 50%; - background: var(--gold-06); + background: var(--gold-12); } .rd-ctrl-play:hover { - background: var(--gold-12); + background: var(--gold-16); } .rd-ctrl-label { font-size: 11px; font-family: var(--mono); - color: rgba(232,213,176,.35); + color: rgba(232,213,176,.62); min-width: 24px; text-align: center; user-select: none; diff --git a/src/PrompterLive.Core/Tps/Services/ScriptCompiler.cs b/src/PrompterLive.Core/Tps/Services/ScriptCompiler.cs index 2fb85c4..4168522 100644 --- a/src/PrompterLive.Core/Tps/Services/ScriptCompiler.cs +++ b/src/PrompterLive.Core/Tps/Services/ScriptCompiler.cs @@ -418,6 +418,12 @@ private static bool TryHandleTagToken(string token, Stack scopeStack return true; } + if (TryParseInlineWpmTag(name, out var inlineWpm)) + { + PushScope(scopeStack, lowered, state => state.SpeedOverride = ClampWpm(inlineWpm)); + return true; + } + if (AvailableColors.TryGetValue(lowered, out var colorHex)) { PushScope(scopeStack, lowered, state => @@ -730,6 +736,23 @@ private static string NormalizeHeadCueId(string? cueId) private static int ClampWpm(int wpm) => Math.Clamp(wpm, MinWpm, MaxWpm); + private static bool TryParseInlineWpmTag(string name, out int wpm) + { + const string suffix = "wpm"; + wpm = 0; + if (string.IsNullOrWhiteSpace(name) || + !name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return int.TryParse( + name[..^suffix.Length], + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out wpm); + } + private static string? NormalizeEmotion(string? emotion) => string.IsNullOrWhiteSpace(emotion) ? null : emotion.Trim(); diff --git a/src/PrompterLive.Shared/Contracts/AboutLinks.cs b/src/PrompterLive.Shared/Contracts/AboutLinks.cs new file mode 100644 index 0000000..95e2855 --- /dev/null +++ b/src/PrompterLive.Shared/Contracts/AboutLinks.cs @@ -0,0 +1,13 @@ +namespace PrompterLive.Shared.Contracts; + +public static class AboutLinks +{ + public const string ProductName = "Prompter.live"; + public const string ManagedCodeName = "Managed Code"; + public const string ManagedCodeWebsiteUrl = "https://www.managed-code.com/"; + public const string ManagedCodeGitHubUrl = "https://github.com/managedcode"; + public const string ProductWebsiteUrl = "https://prompter.managed-code.com/"; + public const string ProductRepositoryUrl = "https://github.com/managedcode/PrompterLive"; + public const string ProductReleasesUrl = ProductRepositoryUrl + "/releases"; + public const string ProductIssuesUrl = ProductRepositoryUrl + "/issues"; +} diff --git a/src/PrompterLive.Shared/Contracts/UiTestIds.cs b/src/PrompterLive.Shared/Contracts/UiTestIds.cs index d1fc3f8..0404250 100644 --- a/src/PrompterLive.Shared/Contracts/UiTestIds.cs +++ b/src/PrompterLive.Shared/Contracts/UiTestIds.cs @@ -172,7 +172,9 @@ public static class Teleprompter public const string Back = "teleprompter-back"; public const string CameraBackground = "teleprompter-camera-layer-primary"; public const string CameraToggle = "teleprompter-camera-toggle"; + public const string Controls = "teleprompter-controls"; public const string EdgeSection = "teleprompter-edge-section"; + public const string EdgeInfo = "teleprompter-edge-info"; public const string FocalSlider = "teleprompter-focal-slider"; public const string FocalGuide = "teleprompter-focal-guide"; public const string FontDown = "teleprompter-font-down"; @@ -183,6 +185,7 @@ public static class Teleprompter public const string PlayToggle = "teleprompter-play-toggle"; public const string PreviousBlock = "teleprompter-previous-block"; public const string PreviousWord = "teleprompter-previous-word"; + public const string Sliders = "teleprompter-sliders"; public const string Stage = "teleprompter-stage"; public const string WidthSlider = "teleprompter-width-slider"; @@ -201,7 +204,14 @@ public static string CardWord(int cardIndex, int groupIndex, int wordIndex) => public static class Settings { public const string AboutAppCard = "settings-about-app-card"; + public const string AboutCompanyCard = "settings-about-company-card"; + public const string AboutCompanyGitHub = "settings-about-company-github"; + public const string AboutCompanyWebsite = "settings-about-company-website"; public const string AboutPanel = "settings-about-panel"; + public const string AboutProductWebsite = "settings-about-product-website"; + public const string AboutRepositoryLink = "settings-about-repository-link"; + public const string AboutReleasesLink = "settings-about-releases-link"; + public const string AboutIssuesLink = "settings-about-issues-link"; public const string AboutVersion = "settings-about-version"; public const string AppearancePanel = "settings-appearance-panel"; public const string AiPanel = "settings-ai-panel"; diff --git a/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor b/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor index 70f20af..a1046bf 100644 --- a/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor +++ b/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor @@ -4,11 +4,11 @@ id="set-about" style="@DisplayStyle" data-testid="@UiTestIds.Settings.AboutPanel"> -

About

-

Prompter.live — professional teleprompter for creators, broadcasters, and public speakers.

+

@AboutSectionTitle

+

@AboutSectionDescription

-
- - Up to date + + @UpToDateLabel
- +
@@ -34,32 +34,38 @@
- + TestId="@UiTestIds.Settings.AboutCompanyCard" + IsOpen="@IsCardOpen(CompanyCardId)" + OnToggle="@(() => ToggleCard.InvokeAsync(CompanyCardId))"> - - - + -
- @foreach (var member in TeamMembers) +

@ManagedCodeCardCopy

+
+ @foreach (var link in CompanyLinks) { -
-
@member.Initials
-
-
@member.Name
-
@member.Role
+ + -
+ + + + }
- @@ -81,8 +87,8 @@
- @@ -94,16 +100,23 @@
@foreach (var link in ResourceLinks) { - + }
- + diff --git a/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor.cs b/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor.cs index 2031930..f2e51f8 100644 --- a/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor.cs +++ b/src/PrompterLive.Shared/Settings/Components/SettingsAboutSection.razor.cs @@ -1,22 +1,48 @@ using Microsoft.AspNetCore.Components; +using PrompterLive.Shared.Contracts; using PrompterLive.Shared.Settings.Services; namespace PrompterLive.Shared.Components.Settings; public partial class SettingsAboutSection { + private const string AboutSectionTitle = "About"; + private const string AboutSectionDescription = "Prompter.live is a professional teleprompter for creators, broadcasters, and public speakers."; + private const string AppCardTitle = AboutLinks.ProductName; private const string AppCardId = "about-app"; + private const string AutomaticUpdatesLabel = "Check for updates automatically"; + private const string CompanyCardId = "about-company"; + private const string FooterText = "Built and maintained by Managed Code."; private const string LicensedStatusLabel = "Licensed"; + private const string ManagedCodeCardCopy = "Everything in Prompter.live is designed, built, and maintained by Managed Code. Use the official links below for the company site, the public GitHub organization, and the live product site."; + private const string ManagedCodeCardSubtitle = "Product, design, and engineering by Managed Code"; + private const string ManagedCodeCardTitle = AboutLinks.ManagedCodeName; private const string OpenSourceCardId = "about-open-source"; + private const string OpenSourceCardSubtitle = "Libraries & licenses"; + private const string OpenSourceCardTitle = "Open Source"; private const string ResourcesCardId = "about-resources"; - private const string TeamCardId = "about-team"; + private const string ResourcesCardSubtitle = "Live app, releases, and support"; + private const string ResourcesCardTitle = "Help & Resources"; + private const string SoftwareUpdatesLabel = "Software Updates"; + private const string UpToDateLabel = "Up to date"; - private static readonly string[] ResourceLinks = + private static readonly AboutLinkItem[] CompanyLinks = [ - "What's New", - "Help & Documentation", - "Report a Bug", - "Privacy Policy" + new( + UiTestIds.Settings.AboutCompanyWebsite, + "Managed Code website", + "Official company website", + AboutLinks.ManagedCodeWebsiteUrl), + new( + UiTestIds.Settings.AboutCompanyGitHub, + "Managed Code on GitHub", + "Official GitHub organization", + AboutLinks.ManagedCodeGitHubUrl), + new( + UiTestIds.Settings.AboutProductWebsite, + "Prompter.live app", + "Live standalone WebAssembly build", + AboutLinks.ProductWebsiteUrl) ]; private static readonly AboutItem[] Libraries = @@ -30,11 +56,23 @@ public partial class SettingsAboutSection new("Web Audio API", "High-level audio processing", "W3C") ]; - private static readonly TeamMember[] TeamMembers = + private static readonly AboutLinkItem[] ResourceLinks = [ - new("M", "Mykola Kovalenko", "Founder · Product & Design", "background:linear-gradient(135deg,#C4A060,#8B6A3A);"), - new("A", "Anna Petrenko", "Lead Engineer", "background:linear-gradient(135deg,#60A5FA,#2563EB);"), - new("D", "Dmytro Shevchenko", "Backend & Infrastructure", "background:linear-gradient(135deg,#34D399,#059669);") + new( + UiTestIds.Settings.AboutRepositoryLink, + "PrompterLive repository", + "Source code, docs, and milestones", + AboutLinks.ProductRepositoryUrl), + new( + UiTestIds.Settings.AboutReleasesLink, + "Release notes", + "Published builds and changelog", + AboutLinks.ProductReleasesUrl), + new( + UiTestIds.Settings.AboutIssuesLink, + "Report an issue", + "Bug reports and product feedback", + AboutLinks.ProductIssuesUrl) ]; [Inject] private IAppVersionProvider AppVersionProvider { get; set; } = null!; @@ -46,6 +84,5 @@ public partial class SettingsAboutSection private string AppCardSubtitle => AppVersionProvider.Current.Subtitle; private sealed record AboutItem(string Name, string Description, string License); - - private sealed record TeamMember(string Initials, string Name, string Role, string AvatarStyle); + private sealed record AboutLinkItem(string TestId, string Label, string Description, string Href); } diff --git a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs index b7702b3..4e420ec 100644 --- a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs +++ b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderAlignment.cs @@ -18,17 +18,38 @@ private async Task AlignActiveReaderTextAsync() } _pendingReaderAlignment = false; + await AlignReaderCardTextAsync(_activeReaderCardIndex, Math.Max(_activeReaderWordIndex, 0), neutralizeCard: false, rerender: true); + } + + private string BuildReaderCardTextStyle(int cardIndex) => + _readerCardTextStyles.TryGetValue(cardIndex, out var style) + ? style + : string.Empty; + + private void RequestReaderAlignment() => _pendingReaderAlignment = true; - if (!TryGetAlignmentWordId(out var wordId)) + private void ResetReaderAlignmentState() + { + _readerCardTextStyles.Clear(); + _pendingReaderAlignment = true; + } + + private Task PrepareReaderCardAlignmentAsync(int cardIndex, int wordOrdinal) => + AlignReaderCardTextAsync(cardIndex, wordOrdinal, neutralizeCard: true, rerender: false); + + private async Task AlignReaderCardTextAsync(int cardIndex, int wordOrdinal, bool neutralizeCard, bool rerender) + { + if (!TryGetAlignmentWordId(cardIndex, wordOrdinal, out var wordId)) { return; } var offsetPixels = await ReaderInterop.MeasureClusterOffsetAsync( UiDomIds.Teleprompter.Stage, - UiDomIds.Teleprompter.CardText(_activeReaderCardIndex), + UiDomIds.Teleprompter.CardText(cardIndex), wordId, - _readerFocalPointPercent); + _readerFocalPointPercent, + neutralizeCard); if (!offsetPixels.HasValue) { @@ -36,33 +57,23 @@ private async Task AlignActiveReaderTextAsync() } var nextStyle = BuildReaderCardTextStyleValue(offsetPixels.Value); - if (_readerCardTextStyles.TryGetValue(_activeReaderCardIndex, out var currentStyle) && + if (_readerCardTextStyles.TryGetValue(cardIndex, out var currentStyle) && string.Equals(currentStyle, nextStyle, StringComparison.Ordinal)) { return; } - _readerCardTextStyles[_activeReaderCardIndex] = nextStyle; - await InvokeAsync(StateHasChanged); - } - - private string BuildReaderCardTextStyle(int cardIndex) => - _readerCardTextStyles.TryGetValue(cardIndex, out var style) - ? style - : string.Empty; - - private void RequestReaderAlignment() => _pendingReaderAlignment = true; - - private void ResetReaderAlignmentState() - { - _readerCardTextStyles.Clear(); - _pendingReaderAlignment = true; + _readerCardTextStyles[cardIndex] = nextStyle; + if (rerender) + { + await InvokeAsync(StateHasChanged); + } } - private bool TryGetAlignmentWordId(out string wordId) + private bool TryGetAlignmentWordId(int cardIndex, int wordOrdinal, out string wordId) { wordId = string.Empty; - if (!TryGetAlignmentWordPosition(out var position)) + if (!TryGetAlignmentWordPosition(cardIndex, wordOrdinal, out var position)) { return false; } @@ -71,16 +82,16 @@ private bool TryGetAlignmentWordId(out string wordId) return true; } - private bool TryGetAlignmentWordPosition(out (int CardIndex, int ChunkIndex, int WordIndex) position) + private bool TryGetAlignmentWordPosition(int cardIndex, int wordOrdinal, out (int CardIndex, int ChunkIndex, int WordIndex) position) { position = default; - if (_activeReaderCardIndex >= _cards.Count) + if (cardIndex < 0 || cardIndex >= _cards.Count) { return false; } - var remainingWords = Math.Max(_activeReaderWordIndex, 0); - var chunks = _cards[_activeReaderCardIndex].Chunks; + var remainingWords = Math.Max(wordOrdinal, 0); + var chunks = _cards[cardIndex].Chunks; for (var chunkIndex = 0; chunkIndex < chunks.Count; chunkIndex++) { @@ -93,7 +104,7 @@ private bool TryGetAlignmentWordPosition(out (int CardIndex, int ChunkIndex, int { if (remainingWords == 0) { - position = (_activeReaderCardIndex, chunkIndex, wordIndex); + position = (cardIndex, chunkIndex, wordIndex); return true; } diff --git a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs index 7607fc1..0dd26be 100644 --- a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs +++ b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderContent.cs @@ -72,7 +72,7 @@ private async Task> BuildReaderCardsAsync() DurationMilliseconds: durationMilliseconds, WidthPercentString: string.Empty, EdgeColor: string.Empty, - Chunks: BuildReaderChunks(words))); + Chunks: BuildReaderChunks(words, targetWpm))); } } @@ -139,7 +139,7 @@ private async Task> CompileBlockWordsAsync(int baseW return compiledBlock?.Words ?? compiledSegment?.Words ?? []; } - private static IReadOnlyList BuildReaderChunks(IEnumerable words) + private static IReadOnlyList BuildReaderChunks(IEnumerable words, int targetWpm) { var chunks = new List(); var currentGroup = new List(); @@ -179,10 +179,15 @@ private static IReadOnlyList BuildReaderChunks(IEnumerable currentCharacterCount += 1; } + var effectiveWpm = ResolveEffectiveWpm(word.Metadata, targetWpm); currentGroup.Add(new ReaderWordViewModel( Text: word.CleanText, - CssClass: BuildReaderWordBaseClass(word.Metadata), - DurationMs: Math.Max(MinimumReaderWordDurationMilliseconds, (int)Math.Round(word.DisplayDuration.TotalMilliseconds)))); + CssClass: BuildReaderWordBaseClass(word.Metadata, targetWpm), + DurationMs: Math.Max(MinimumReaderWordDurationMilliseconds, (int)Math.Round(word.DisplayDuration.TotalMilliseconds)), + Style: BuildReaderWordStyle(word.Metadata, targetWpm, effectiveWpm), + Title: BuildReaderWordTitle(word.Metadata, targetWpm, effectiveWpm), + PronunciationGuide: string.IsNullOrWhiteSpace(word.Metadata?.PronunciationGuide) ? null : word.Metadata.PronunciationGuide.Trim(), + EffectiveWpm: effectiveWpm)); if (ShouldEndReaderGroup(word.CleanText, currentGroup.Count, currentCharacterCount)) { @@ -221,7 +226,7 @@ private static void FlushGroup(List chunks, List= 175 ? "tps-fast" : "tps-slow"); + classes.Add("tps-xslow"); } - else if (metadata.SpeedMultiplier.HasValue) + else if (speedRatio < 0.95d) { - if (metadata.SpeedMultiplier <= 0.65f) - { - classes.Add("tps-xslow"); - } - else if (metadata.SpeedMultiplier < 1f) - { - classes.Add("tps-slow"); - } - else if (metadata.SpeedMultiplier >= 1.45f) - { - classes.Add("tps-xfast"); - } - else if (metadata.SpeedMultiplier > 1f) - { - classes.Add("tps-fast"); - } + classes.Add("tps-slow"); + } + else if (speedRatio >= 1.45d) + { + classes.Add("tps-xfast"); + } + else if (speedRatio > 1.05d) + { + classes.Add("tps-fast"); } if (!string.IsNullOrWhiteSpace(metadata.PronunciationGuide)) @@ -349,6 +349,7 @@ private static string ResolveColorClass(string? color, string prefix) "#ec4899" or "pink" => $"{prefix}-pink", "#14b8a6" or "teal" => $"{prefix}-teal", "#ffffff" or "white" => $"{prefix}-white", + "#111827" or "black" => $"{prefix}-gray", "#6b7280" or "gray" => $"{prefix}-gray", "#ffeb3b" or "highlight" => $"{prefix}-highlight", _ => string.Empty diff --git a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs index 498167e..8fb7251 100644 --- a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs +++ b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderModels.cs @@ -55,7 +55,15 @@ private sealed record ReaderGroupViewModel(IReadOnlyList Wo private sealed record ReaderPauseViewModel(int DurationMs, string CssClass) : ReaderChunkViewModel; - private sealed record ReaderWordViewModel(string Text, string CssClass, int DurationMs, int PauseAfterMs = 0); + private sealed record ReaderWordViewModel( + string Text, + string CssClass, + int DurationMs, + int PauseAfterMs = 0, + string? Style = null, + string? Title = null, + string? PronunciationGuide = null, + int? EffectiveWpm = null); private sealed record ReaderCameraLayerViewModel( string ElementId, diff --git a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs index 2c11ade..4f58ed2 100644 --- a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs +++ b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPlayback.cs @@ -126,6 +126,7 @@ private async Task StartReaderCountdownAsync() await Task.Delay(ReaderFirstWordDelayMilliseconds, cancellationToken); + await PrepareReaderCardAlignmentAsync(_activeReaderCardIndex, 0); _activeReaderWordIndex = 0; UpdateReaderDisplayState(); _isReaderPlaying = true; @@ -192,6 +193,7 @@ private async Task JumpReaderCardAsync(int direction) if (direction < 0 && _activeReaderWordIndex > 1) { + await PrepareReaderCardAlignmentAsync(_activeReaderCardIndex, 0); _activeReaderWordIndex = 0; UpdateReaderDisplayState(); @@ -325,6 +327,7 @@ private async Task AdvanceReaderPlaybackAsync(CancellationToken cancellatio private async Task AdvanceToCardAsync(int nextCardIndex, CancellationToken cancellationToken) { + await PrepareReaderCardAlignmentAsync(nextCardIndex, 0); _activeReaderCardIndex = nextCardIndex; _activeReaderWordIndex = -1; UpdateReaderDisplayState(); diff --git a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs new file mode 100644 index 0000000..f4bd438 --- /dev/null +++ b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.ReaderWordStyling.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using PrompterLive.Core.Models.CompiledScript; + +namespace PrompterLive.Shared.Pages; + +public partial class TeleprompterPage +{ + private const double FastLetterSpacingDeadZoneRatio = 0.05d; + private const double MaximumFastLetterSpacingEm = -0.028d; + 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 SlowLetterSpacingRangeRatio = 0.4d; + private const double FastLetterSpacingRangeRatio = 0.55d; + private const double MaximumSlowLetterSpacingEm = 0.058d; + private const string WpmSuffix = " WPM"; + + private static string? BuildReaderWordStyle(WordMetadata? metadata, int targetWpm, int effectiveWpm) + { + if (metadata is null) + { + return null; + } + + var referenceWpm = Math.Max(MinimumReaderReferenceWpm, targetWpm); + var speedRatio = effectiveWpm / (double)referenceWpm; + if (Math.Abs(speedRatio - 1d) <= FastLetterSpacingDeadZoneRatio) + { + return null; + } + + var letterSpacingEm = speedRatio < 1d + ? Math.Min( + MaximumSlowLetterSpacingEm, + MaximumSlowLetterSpacingEm * (1d - speedRatio) / SlowLetterSpacingRangeRatio) + : -Math.Min( + Math.Abs(MaximumFastLetterSpacingEm), + Math.Abs(MaximumFastLetterSpacingEm) * (speedRatio - 1d) / FastLetterSpacingRangeRatio); + + if (Math.Abs(letterSpacingEm) < 0.001d) + { + return null; + } + + return string.Create( + CultureInfo.InvariantCulture, + $"{ReaderWordLetterSpacingVariable}:{letterSpacingEm:0.###}em;"); + } + + private static string? BuildReaderWordTitle(WordMetadata? metadata, int targetWpm, int effectiveWpm) + { + if (metadata is null) + { + return null; + } + + var titleParts = new List(); + if (!string.IsNullOrWhiteSpace(metadata.PronunciationGuide)) + { + titleParts.Add(PronunciationTitlePrefix + metadata.PronunciationGuide.Trim()); + } + + if (effectiveWpm != targetWpm) + { + titleParts.Add(ReaderSpeedTitlePrefix + effectiveWpm.ToString(CultureInfo.InvariantCulture) + WpmSuffix); + } + + return titleParts.Count == 0 + ? null + : string.Join(" · ", titleParts); + } + + private static int ResolveEffectiveWpm(WordMetadata? metadata, int targetWpm) + { + var referenceWpm = Math.Max(MinimumReaderReferenceWpm, targetWpm); + if (metadata?.SpeedOverride is int speedOverride) + { + return Math.Max(MinimumReaderReferenceWpm, speedOverride); + } + + if (metadata?.SpeedMultiplier is float speedMultiplier && speedMultiplier > 0f) + { + return Math.Max( + MinimumReaderReferenceWpm, + (int)Math.Round(referenceWpm * speedMultiplier, MidpointRounding.AwayFromZero)); + } + + return referenceWpm; + } +} diff --git a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.razor b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.razor index 17f1bfd..124f8b8 100644 --- a/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.razor +++ b/src/PrompterLive.Shared/Teleprompter/Pages/TeleprompterPage.razor @@ -31,7 +31,7 @@
@BuildCountdownLabel()
-
+
@word.Text @if (wordIndex < group.Words.Count - 1) { @@ -145,7 +149,7 @@ }
-
+
@_edgeSectionLabel @BuildElapsedLabel()
-
+