diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe49da..fb614f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Semantic Versioning independently of the version-1 trace and program schemas. ## Unreleased +### Fixed (narration review) + +- Coordinate browser-global speech synthesis between multiple Throughline players; newer narration takes over while displaced players pause and stop owning cancellation. +- Pause with an accessible error on unexpected speech interruption instead of stalling autoplay or advancing a failed utterance. +- Restart narration for repeated seeks to the same step and replace stale speech when authored guidance changes. +- Keep the dual-audio 360px breakpoint while restoring a generic three-row fallback at <=300px content widths, covering players with no audio, only narration, only step tones, or both. Verify embedded layouts with actual browser element bounds. + +### Added (opt-in caption narration) + +- Offer muted-by-default browser caption narration separately from step tones, using speech-synchronized playback without new runtime dependencies. +- Pause/resume speaking with playback; cancel on seek, scenario change and unmount; preserve the original timer when narration is off. + + ### Changed (development preview sound) - Expose the existing opt-in Web Audio speaker control in every local playground example and scenario comparison, initially muted. No default audio in the published React player and no new audio dependencies. diff --git a/README.md b/README.md index 6d33681..efb47c5 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,44 @@ from the diagram. The screen-reader step announcement remains active regardless of whether the disclosure is expanded. Details remain open between steps until closed, without changing playback state. +## Opt-in caption narration + +The optional React-only `narration` prop exposes a **Narrate captions** +button next to the independent `sonification` sound-effects toggle. +The button is **off initially**, even when a host offers it. Its first +activation speaks the current authored step from a user gesture using +the browser's Speech Synthesis API. It reads the authored step label, +notes, matching chapter and causal explanation, and transfer summaries. +It does not invent responses, state changes or causal explanations. +Very long utterances are shortened with an explicit spoken notice; the +full authored text remains available in Details. + +With narration enabled, **Play advances only after speech finishes**. +Pause pauses the current voice; Play resumes it. Manual seeking or +stepping cancels the previous voice and narrates the selected step. +Switching scenarios cancels the old voice and starts muted. Disabling +narration returns to timed playback and leaves step tones independent. +Playback speed applies to the next utterance, not the current one. +A manual seek to the **same** step (including Restart chapter) restarts its +narration. If an author updates guidance while the current frame is shown, +the old utterance is invalidated and the corrected explanation is spoken. + +Speech voices, available hardware, and user-activation policies depend +on the browser and OS. The button is disabled where speech synthesis is +unavailable; speech errors pause synchronized playback and are announced. +No text is uploaded by Throughline, but the browser may use a network +speech service. The core stays React- and browser-independent. + +**Multiple embedded players:** Browser speech synthesis has one global queue. +Throughline coordinates narration across its player instances: when B begins +speaking, B takes over and A is paused, unmutes no further audio, and reports +that another player is speaking. A's later seek/unmount does not interrupt B. +A may explicitly re-enable narration to take over again. External scripts +calling the browser speech API are not under Throughline's control; an +unexpected `interrupted` or `canceled` utterance is treated as an error: +narration-driven playback pauses and shows a recoverable message. This +behavior does not imply exclusive ownership of third-party speech. + ## Persistent workflow progress The React player retains a cumulative trail through the **selected** step. diff --git a/docs/architecture.md b/docs/architecture.md index fb602bc..de65d7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -60,8 +60,43 @@ uses this internal boundary without adding a package export, trace schema, viewport control, or public selection API. Pan/zoom, automatic camera following and user-driven collapse are not part of TL-I12. +## Optional spoken explanation + +The React-only narration controller synthesizes authored caption text +(frame label, notes, guided chapter/causal content and explicit sends). +No new trace event or compiler schema is introduced. + +While narration is enabled, speech completion drives playback ticks +instead of a fixed interval. Only the active utterance may advance +the selected trace; seeking (including to the same step) and changed +authored guidance invalidate stale callbacks. Pause/resume +controls the voice, and unavailable or failed synthesis pauses the +narration clock with an accessible error. When narration is disabled, +the existing timed playback returns. Step tones remain independent. + +Browser speechSynthesis is a shared page resource. Throughline maintains a +per-engine, browser-local owner coordinator: the newest Throughline player +to begin narration takes over the global speech queue, pauses/disables the +previous player and announces displacement. Only the current owner may +call global `cancel()`, `pause()` or `resume()`; a displaced player's later +seek, toggle or unmount cannot cancel the new owner's speech. Deliberate +cancellation detaches event handlers before calling the browser API, while +unexpected `canceled`/`interrupted` errors trigger an accessible error +and pause rather than advancing the timeline. After a natural speech end, +ownership is released without global cancellation. This mechanism does not +coordinate with arbitrary third-party scripts using speechSynthesis and +cannot guarantee browser/OS voice isolation. Some voices may use +browser-managed remote services. + ## Footer layout and bounded explanations +The optional narration and step-tone controls share responsive rules. +Dual-audio players use a dedicated utilities row at player content widths up +to 360px; **all** players use the generic three-row fallback at content +widths up to 300px, regardless of whether audio features are offered. +Both breakpoints use the measured player container rather than the viewport, +preserving accessible controls in narrow embedded layouts. + The React player composes its navigation, chapter selection, timeline, and transport in one bottom bar adjacent to the diagram. Container queries use the player container's width, not global browser size, diff --git a/playground/main.tsx b/playground/main.tsx index 99c3031..b177187 100644 --- a/playground/main.tsx +++ b/playground/main.tsx @@ -90,6 +90,7 @@ function App() { scenarios={scenarios} initialScenarioId="retry-recovery" theme={theme} + narration sonification={{}} /> ) : current ? ( @@ -99,6 +100,7 @@ function App() { title={current.title} theme={theme} sonification={{}} + narration guidance={current.id === "distributed-checkout" ? CHECKOUT_GUIDANCE : undefined} /> ) : null} @@ -110,7 +112,10 @@ function App() {

Use the player controls to step, pan, zoom, collapse groups, inspect state - and switch outcomes. Sound is available in every example: select the speaker + and switch outcomes. Narration is available in every example: + select Narrate captions to hear the authored explanation. + Play waits for speech to finish before advancing. The separate + step-tone sound control is still available: select the speaker icon to unmute, then Play or step forward to hear short synthesized tones. It starts muted and does not narrate the workflow. Resize the browser or choose the 375px view to find layout problems. Changes to the source reload diff --git a/src/react/__tests__/narration-review.browser.test.tsx b/src/react/__tests__/narration-review.browser.test.tsx new file mode 100644 index 0000000..4c67cca --- /dev/null +++ b/src/react/__tests__/narration-review.browser.test.tsx @@ -0,0 +1,244 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; +import { render } from "vitest-browser-react"; +import { compile } from "../../domains/registry.js"; +import type { ThroughlineGuidance } from "../guidance.js"; +import { ThroughlinePlayer } from "../index.js"; +import "../../styles/throughline.css"; + +const trace = compile( + 'thl flow\nnode a "Client" = 0\nstep "Request"\n set a = 1\n note "Request started"\nstep "Response"\n set a = 2\n note "Response received"', +); + +function speechBoundary() { + class FakeUtterance { + rate = 1; + onend: (() => void) | null = null; + onerror: ((event: { error: string }) => void) | null = null; + constructor(public text: string) {} + } + const voices: FakeUtterance[] = []; + let active: FakeUtterance | null = null; + const synth = { + paused: false, + speak: vi.fn((voice: FakeUtterance) => { + active = voice; + voices.push(voice); + }), + cancel: vi.fn(() => { + const voice = active; + active = null; + voice?.onerror?.({ error: "interrupted" }); + }), + pause: vi.fn(() => { synth.paused = true; }), + resume: vi.fn(() => { synth.paused = false; }), + }; + vi.stubGlobal("SpeechSynthesisUtterance", FakeUtterance); + vi.stubGlobal("speechSynthesis", synth); + return { voices, synth }; +} +afterEach(() => vi.unstubAllGlobals()); + +it("reports an external interruption and pauses without completing the step", async () => { + const speech = speechBoundary(); + const screen = await render(); + const narrator = screen.getByRole("button", { name: "Narrate captions" }); + await narrator.click(); + await screen.getByRole("button", { name: "Play", exact: true }).click(); + const abandoned = speech.voices[0].onend; + speech.voices[0].onerror?.({ error: "interrupted" }); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Speech stopped (interrupted)"); + await expect.element(screen.getByRole("button", { name: "Play", exact: true })).toBeEnabled(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + abandoned?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + await narrator.click(); + await narrator.click(); + expect(speech.voices).toHaveLength(2); +}); + +it("takes over speech across two players and never cancels B when displaced A seeks or unmounts", async () => { + const speech = speechBoundary(); + const screen = await render( +

+ + +
, + ); + const all = [...document.querySelectorAll(".thl")]; + expect(all).toHaveLength(2); + const a = all[0]; + const b = all[1]; + const aSound = a.querySelector(".thl-narration")!; + const bSound = b.querySelector(".thl-narration")!; + const aPlay = a.querySelector(".thl-play")!; + await userEvent.click(aSound); + await userEvent.click(aPlay); + expect(speech.voices).toHaveLength(1); + const stale = speech.voices[0].onend; + await userEvent.click(bSound); + expect(speech.voices).toHaveLength(2); + await expect.poll(() => aSound.getAttribute("aria-pressed")).toBe("false"); + await expect.poll(() => aPlay.getAttribute("aria-label")).toBe("Play"); + expect(a.querySelector('[role="alert"]')?.textContent).toContain("another Throughline player"); + expect(bSound.getAttribute("aria-pressed")).toBe("true"); + stale?.(); + expect(a.querySelector('[role="status"]')?.textContent).toContain("Step 1 of 3"); + const count = speech.synth.cancel.mock.calls.length; + await userEvent.click(a.querySelector('button[aria-label="Step forward"]')!); + expect(speech.synth.cancel.mock.calls.length).toBe(count); + expect(bSound.getAttribute("aria-pressed")).toBe("true"); + await screen.rerender( +
, + ); + expect(speech.synth.cancel.mock.calls.length).toBe(count); + expect(speech.voices).toHaveLength(2); + expect(document.querySelector(".thl-narration")?.getAttribute("aria-pressed")).toBe("true"); +}); + +it("preserves B's active voice when the unmounted A had already yielded ownership", async () => { + const speech = speechBoundary(); + const screen = await render( +
+ + +
, + ); + const players = [...document.querySelectorAll(".thl")]; + await userEvent.click(players[0].querySelector(".thl-narration")!); + await userEvent.click(players[1].querySelector(".thl-narration")!); + const count = speech.synth.cancel.mock.calls.length; + await screen.rerender( +
, + ); + expect(speech.synth.cancel.mock.calls.length).toBe(count); + expect(speech.voices).toHaveLength(2); + await screen.getByRole("button", { name: "Step forward" }).click(); + expect(speech.voices).toHaveLength(3); +}); + +it("replaces stale guidance on the same frame without allowing the old voice to advance", async () => { + const speech = speechBoundary(); + const first: ThroughlineGuidance = { + chapters: [{ id: "one", title: "Original instructions", start: 0, end: 2 }], + }; + const updated: ThroughlineGuidance = { + chapters: [{ id: "one", title: "Corrected instructions", start: 0, end: 2 }], + }; + const screen = await render(); + await screen.getByRole("button", { name: "Narrate captions" }).click(); + await screen.getByRole("button", { name: "Play", exact: true }).click(); + expect(speech.voices[0].text).toContain("Original instructions"); + const stale = speech.voices[0].onend; + await screen.rerender(); + expect(speech.voices).toHaveLength(2); + expect(speech.voices[1].text).toContain("Corrected instructions"); + expect(speech.voices[1].text).not.toContain("Original instructions"); + stale?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + speech.voices[1].onend?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 2 of 3"); +}); + +it("restarts same-frame narration, including after the old utterance completed", async () => { + const speech = speechBoundary(); + const guidance: ThroughlineGuidance = { + chapters: [{ id: "opening", title: "Opening chapter", start: 0, end: 2 }], + }; + const screen = await render(); + await screen.getByRole("button", { name: "Narrate captions" }).click(); + const restart = screen.getByRole("button", { name: "Restart chapter" }); + const old = speech.voices[0].onend; + await restart.click(); + expect(speech.voices).toHaveLength(2); + old?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + speech.voices[1].onend?.(); + await restart.click(); + expect(speech.voices).toHaveLength(3); + expect(speech.voices[2].text).toContain("Opening chapter"); +}); + +it("keeps two audio controls within embedded content widths of 300–320px", async () => { + await page.viewport(1100, 850); + speechBoundary(); + const widths = [348, 356, 366, 368, 380, 420]; + const screen = await render( +
+ +
, + ); + for (const width of widths) { + await screen.rerender( +
+ +
, + ); + const player = document.querySelector(".thl")!; + const controls = player.querySelector(".thl-controls")!; + const bounds = controls.getBoundingClientRect(); + await expect.poll(() => Math.round(player.getBoundingClientRect().width)).toBe(width); + expect(player.scrollWidth, "player at " + width).toBeLessThanOrEqual(player.clientWidth); + for (const item of controls.querySelectorAll("button, select, input")) { + const rect = item.getBoundingClientRect(); + expect(rect.left, "left control at " + width).toBeGreaterThanOrEqual(bounds.left - 1); + expect(rect.right, "right control at " + width).toBeLessThanOrEqual(bounds.right + 1); + } + if (width <= 400) { + const transport = controls.querySelector(".thl-transport")!.getBoundingClientRect(); + const utilities = controls.querySelector(".thl-utilities")!.getBoundingClientRect(); + expect(utilities.top, "dedicated utility row at " + width).toBeGreaterThan(transport.top); + } + } +}); + +it("keeps controls visible with neither, one, or both optional audio features at narrow embed widths", async () => { + await page.viewport(1100, 850); + speechBoundary(); + const configurations = [ + { name: "no audio", narration: false, sonification: false }, + { name: "narration only", narration: true, sonification: false }, + { name: "sound effects only", narration: false, sonification: true }, + { name: "both audio controls", narration: true, sonification: true }, + ] as const; + const widths = [260, 280, 296, 300, 320, 348, 368, 380, 420]; + const screen = await render( +
+ +
, + ); + for (const configuration of configurations) { + for (const width of widths) { + await screen.rerender( +
+ +
, + ); + const label = configuration.name + " at " + width + "px"; + const player = document.querySelector(".thl")!; + const controls = player.querySelector(".thl-controls")!; + const bounds = controls.getBoundingClientRect(); + await expect.poll(() => Math.round(player.getBoundingClientRect().width)).toBe(width); + expect(player.scrollWidth, label).toBeLessThanOrEqual(player.clientWidth); + for (const item of controls.querySelectorAll("button, select, input")) { + const rect = item.getBoundingClientRect(); + expect(rect.left, label + " left").toBeGreaterThanOrEqual(bounds.left - 1); + expect(rect.right, label + " right").toBeLessThanOrEqual(bounds.right + 1); + } + const contentWidth = width - 48; + const mustSplit = contentWidth <= 300 || + (configuration.narration && configuration.sonification && contentWidth <= 360); + const transport = controls.querySelector(".thl-transport")!.getBoundingClientRect(); + const utilities = controls.querySelector(".thl-utilities")!.getBoundingClientRect(); + if (mustSplit) { + expect(utilities.top, label + " utility row").toBeGreaterThan(transport.top); + } else { + expect(Math.abs(utilities.top - transport.top), label + " aligned rows").toBeLessThan(2); + } + } + } +}); diff --git a/src/react/__tests__/narration-text.test.ts b/src/react/__tests__/narration-text.test.ts new file mode 100644 index 0000000..d62884c --- /dev/null +++ b/src/react/__tests__/narration-text.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vitest"; +import { compile } from "../../domains/registry.js"; +import { CHECKOUT_GUIDANCE, EXAMPLES } from "../../examples.js"; +import { narrationText } from "../narration-text.js"; + +it("speaks only authored current-frame notes, chapters, evidence and explicit transfers", () => { + const trace = compile(EXAMPLES.find((item) => item.id === "distributed-checkout")!.source); + const frame = trace.frames.find((item) => item.label === "Receive confirmed result")!; + const text = narrationText(frame, trace, CHECKOUT_GUIDANCE); + expect(text).toContain("Receive confirmed result"); + expect(text).toContain("provider to worker"); + expect(text).not.toContain("Unwritten response"); + expect(narrationText(trace.frames[0], trace)).toBe(trace.frames[0].label); +}); + +it("bounds browser utterance input without changing the compiled authored trace", () => { + const trace = compile('thl flow\nnode a "Author" = 0\nstep "A very long note"\n note "' + "A".repeat(4000) + '"'); + const before = structuredClone(trace); + const speech = narrationText(trace.frames[1], trace); + expect(speech.length).toBeLessThanOrEqual(3000); + expect(speech).toContain("A very long note"); + expect(speech).toContain("Narration shortened. Read Details"); + expect(trace).toEqual(before); +}); diff --git a/src/react/__tests__/narration.browser.test.tsx b/src/react/__tests__/narration.browser.test.tsx new file mode 100644 index 0000000..e6de2bb --- /dev/null +++ b/src/react/__tests__/narration.browser.test.tsx @@ -0,0 +1,172 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; +import { compile } from "../../domains/registry.js"; +import { ThroughlinePlayer, ThroughlineScenarios } from "../index.js"; +import "../../styles/throughline.css"; + +const trace = compile( + 'thl flow\nnode n "State" = 0\nstep "First"\n set n = 1\n note "Request started"\nstep "Second"\n set n = 2\n note "Server replied"', +); +afterEach(() => vi.unstubAllGlobals()); + +function speechBoundary() { + class FakeUtterance { + rate = 1; + onend: (() => void) | null = null; + onerror: ((event: { error: string }) => void) | null = null; + constructor(public text: string) {} + } + const voices: FakeUtterance[] = []; + let active: FakeUtterance | null = null; + const synth = { + paused: false, + speak: vi.fn((voice: FakeUtterance) => { active = voice; voices.push(voice); }), + cancel: vi.fn(() => { + if (active) active.onerror?.({ error: "canceled" }); + active = null; + }), + pause: vi.fn(() => { synth.paused = true; }), + resume: vi.fn(() => { synth.paused = false; }), + }; + vi.stubGlobal("SpeechSynthesisUtterance", FakeUtterance); + vi.stubGlobal("speechSynthesis", synth); + return { voices, synth }; +} + +it("does not speak without user activation, and leaves the original sonification independent", async () => { + const speech = speechBoundary(); + const screen = await render(); + const button = screen.getByRole("button", { name: "Narrate captions" }); + await expect.element(button).toHaveAttribute("aria-pressed", "false"); + expect(speech.voices).toHaveLength(0); + expect(screen.getByRole("button", { name: "Sonify trace" }).element()).not.toBeNull(); + + await button.click(); + await expect.element(button).toHaveAttribute("aria-pressed", "true"); + expect(speech.voices).toHaveLength(1); + expect(speech.voices[0].text).toContain(trace.frames[0].label); + expect(speech.synth.speak).toHaveBeenCalledTimes(1); + await screen.getByRole("button", { name: "Step forward" }).click(); + expect(speech.voices).toHaveLength(2); + expect(speech.voices[1].text).toContain("Request started"); + await button.click(); + await expect.element(button).toHaveAttribute("aria-pressed", "false"); + expect(speech.synth.cancel).toHaveBeenCalled(); +}); + +it("lets speech completion, rather than an interval, drive autoplay", async () => { + const speech = speechBoundary(); + const screen = await render(); + await screen.getByRole("button", { name: "Narrate captions" }).click(); + const play = screen.getByRole("button", { name: "Play", exact: true }); + await play.click(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + // The speech clock must hold this step even after the normal 1.5s timer. + await new Promise((resolve) => setTimeout(resolve, 1650)); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + speech.voices[0].onend?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 2 of 3"); + expect(speech.voices).toHaveLength(2); + speech.voices[1].onend?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 3 of 3"); + speech.voices[2].onend?.(); + await expect.element(screen.getByRole("button", { name: "Play", exact: true })).toBeEnabled(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 3 of 3"); +}); + +it("pauses, resumes and cancels stale callbacks when seeking", async () => { + const speech = speechBoundary(); + const screen = await render(); + await screen.getByRole("button", { name: "Narrate captions" }).click(); + await screen.getByRole("button", { name: "Play", exact: true }).click(); + await screen.getByRole("button", { name: "Pause" }).click(); + expect(speech.synth.pause).toHaveBeenCalled(); + await screen.getByRole("button", { name: "Play", exact: true }).click(); + expect(speech.synth.resume).toHaveBeenCalled(); + const stale = speech.voices[0].onend; + await screen.getByRole("slider", { name: "Timeline" }).fill("2"); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 3 of 3"); + expect(speech.voices).toHaveLength(2); + stale?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 3 of 3"); +}); + +it("disables unavailable narration and never starts speech without an offered prop", async () => { + vi.stubGlobal("SpeechSynthesisUtterance", undefined); + vi.stubGlobal("speechSynthesis", undefined); + const screen = await render(); + const narrator = screen.getByRole("button", { name: "Narrate captions" }); + await expect.element(narrator).toBeDisabled(); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Narrate captions" }).query()).toBeNull(); + await screen.getByRole("button", { name: "Play", exact: true }).click(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); +}); + +it("resets speech control and invalidates old voices on independent scenario changes", async () => { + const speech = speechBoundary(); + const screen = await render( + , + ); + await screen.getByRole("button", { name: "Narrate captions" }).click(); + expect(speech.voices).toHaveLength(1); + const previous = speech.voices[0].onend; + await screen.getByRole("combobox", { name: "Scenario" }).selectOptions("second"); + await expect.element(screen.getByRole("button", { name: "Narrate captions" })).toHaveAttribute("aria-pressed", "false"); + previous?.(); + expect(speech.voices).toHaveLength(1); + expect(speech.synth.cancel).toHaveBeenCalled(); +}); + +it("holds the current step after a speech failure instead of treating failure as completion", async () => { + const speech = speechBoundary(); + const screen = await render(); + const button = screen.getByRole("button", { name: "Narrate captions" }); + await button.click(); + speech.voices[0].onerror?.({ error: "not-allowed" }); + await expect.element(screen.getByRole("alert")).toHaveTextContent("Turn narration off and on to retry."); + await screen.getByRole("button", { name: "Play", exact: true }).click(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + speech.voices[0].onend?.(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3"); + await button.click(); + await button.click(); + expect(speech.voices).toHaveLength(2); +}); + +it("preserves mobile touch controls when narration and sound effects are both offered", async () => { + const { page } = await import("vitest/browser"); + await page.viewport(320, 750); + speechBoundary(); + const screen = await render(); + await expect.element(screen.getByRole("button", { name: "Narrate captions" })).toBeEnabled(); + const player = document.querySelector(".thl")!; + const controls = document.querySelector(".thl-controls")!; + const bounds = controls.getBoundingClientRect(); + expect(player.scrollWidth).toBeLessThanOrEqual(player.clientWidth); + for (const item of controls.querySelectorAll("button, select, input")) { + const rect = item.getBoundingClientRect(); + expect(rect.left).toBeGreaterThanOrEqual(bounds.left - 1); + expect(rect.right).toBeLessThanOrEqual(bounds.right + 1); + } + expect(screen.getByRole("button", { name: "Narrate captions" }).element().getBoundingClientRect().height).toBeGreaterThanOrEqual(40); +}); + +it("stops an active voice and restores the normal clock if a host removes narration", async () => { + const speech = speechBoundary(); + const screen = await render(); + await screen.getByRole("button", { name: "Narrate captions" }).click(); + expect(speech.voices).toHaveLength(1); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Narrate captions" }).query()).toBeNull(); + expect(speech.synth.cancel).toHaveBeenCalled(); + await screen.rerender(); + await expect.element(screen.getByRole("button", { name: "Narrate captions" })).toHaveAttribute("aria-pressed", "false"); + expect(speech.voices).toHaveLength(1); +}); diff --git a/src/react/controls.tsx b/src/react/controls.tsx index 7194bf3..77585f5 100644 --- a/src/react/controls.tsx +++ b/src/react/controls.tsx @@ -10,6 +10,7 @@ interface PlayerControlsProps { state: SeekState; dispatch: React.Dispatch; sound?: { enabled: boolean; toggle: () => void }; + narration?: { enabled: boolean; available: boolean; toggle: () => void }; fullscreen: { active: boolean; available: boolean; toggle: () => void }; } @@ -17,6 +18,7 @@ export function PlayerControls({ state, dispatch, sound, + narration, fullscreen, }: PlayerControlsProps): React.JSX.Element { return ( @@ -121,6 +123,22 @@ export function PlayerControls({ )} + {narration && ( + + )}