@tinytars/frame 0.1.29 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PersonaBubble.svelte +63 -20
- package/SpeechControls.svelte +47 -0
- package/package.json +3 -1
- package/retell-registry.svelte.ts +18 -0
- package/speech-registry.svelte.ts +174 -35
package/PersonaBubble.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import LeafActionMenu from "@tinytars/frame/LeafActionMenu.svelte";
|
|
4
4
|
import type { LeafMenuItem } from "@tinytars/frame/menu-items";
|
|
5
5
|
import { speechRegistry, isSpeechSupported } from "./speech-registry.svelte";
|
|
6
|
+
import { currentRetell } from "./retell-registry.svelte";
|
|
6
7
|
|
|
7
8
|
// The one persona treatment: a tinted bubble with a head row (uppercase persona tag on the
|
|
8
9
|
// left; an optional meta/date and an optional action icon, e.g. download, on the right). Colors
|
|
@@ -24,58 +25,100 @@
|
|
|
24
25
|
onTogglePin?: () => void;
|
|
25
26
|
pinDisplay?: "auto" | "hidden";
|
|
26
27
|
actions?: BubbleAction[];
|
|
28
|
+
// Opaque to this package: handed to the app's configured SpeechEngine.
|
|
29
|
+
voice?: string;
|
|
30
|
+
// False where the body is already a chosen telling (e.g. a chat reply in the picked persona).
|
|
31
|
+
retellable?: boolean;
|
|
27
32
|
children?: Snippet;
|
|
28
33
|
}
|
|
29
|
-
let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, children }: Props = $props();
|
|
34
|
+
let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, voice, retellable = true, children }: Props = $props();
|
|
30
35
|
|
|
31
|
-
// Every assistant-generated bubble gets
|
|
32
|
-
// time
|
|
33
|
-
//
|
|
34
|
-
// singleton (mirrors menu-registry.svelte.ts); clicking the currently-speaking bubble's own button
|
|
35
|
-
// again toggles it off (speechRegistry.speak handles that toggle internally).
|
|
36
|
+
// Every assistant-generated bubble gets read-aloud for free. It reads bodyEl's rendered text at
|
|
37
|
+
// click time instead of taking a prop, so no caller has to change. speechRegistry is the shared
|
|
38
|
+
// player, and SpeechControls keeps playback controllable after this bubble unmounts.
|
|
36
39
|
const uid = $props.id();
|
|
37
40
|
const bubbleId = `${persona}-${uid}`;
|
|
38
41
|
const speakable = $derived(persona === "assistant" && isSpeechSupported());
|
|
39
|
-
const
|
|
42
|
+
const speech = $derived(speechRegistry.statusOf(bubbleId));
|
|
43
|
+
const speakTitle = $derived(speech === "playing" ? "Pause reading" : speech === "paused" ? "Resume reading" : "Read aloud");
|
|
40
44
|
let bodyEl: HTMLDivElement | undefined;
|
|
45
|
+
|
|
46
|
+
// A retelling replaces the body in place, so read-aloud and the label follow what is on screen.
|
|
47
|
+
// It lasts only while the app still offers that retelling.
|
|
48
|
+
const retell = $derived(persona === "assistant" && retellable && children ? currentRetell() : null);
|
|
49
|
+
let retold = $state<{ by: string; text: string } | null>(null);
|
|
50
|
+
let retelling = $state(false);
|
|
51
|
+
const shownRetold = $derived(retold && retell && retold.by === retell.label ? retold : null);
|
|
52
|
+
const shownLabel = $derived(shownRetold ? shownRetold.by : label);
|
|
53
|
+
async function toggleRetell() {
|
|
54
|
+
if (shownRetold) { retold = null; return; }
|
|
55
|
+
const r = retell!;
|
|
56
|
+
retelling = true;
|
|
57
|
+
const text = await r.retell(bodyEl?.textContent ?? "").catch(() => null);
|
|
58
|
+
retelling = false;
|
|
59
|
+
retold = text ? { by: r.label, text } : null;
|
|
60
|
+
}
|
|
61
|
+
const menuItems = $derived<BubbleAction[]>([
|
|
62
|
+
...(actions ?? []),
|
|
63
|
+
...(retell ? [{
|
|
64
|
+
key: "retell",
|
|
65
|
+
label: retelling ? `${retell.label}…` : shownRetold ? "Show original" : retell.label,
|
|
66
|
+
disabled: retelling,
|
|
67
|
+
onClick: toggleRetell,
|
|
68
|
+
}] : []),
|
|
69
|
+
]);
|
|
70
|
+
|
|
41
71
|
function toggleSpeak() {
|
|
42
|
-
speechRegistry.
|
|
72
|
+
speechRegistry.toggle(bubbleId, bodyEl?.textContent ?? "", meta ? `${shownLabel} · ${meta}` : shownLabel, shownRetold ? retell?.voice : voice);
|
|
43
73
|
}
|
|
44
74
|
</script>
|
|
45
75
|
|
|
46
|
-
<div class="persona-bubble p-{persona}" {id}>
|
|
76
|
+
<div class="persona-bubble p-{persona}" {id} data-speech-id={speakable ? bubbleId : undefined}>
|
|
47
77
|
<div class="persona-head">
|
|
48
78
|
<span class="persona-head-left">
|
|
49
|
-
<span class="persona-tag">{
|
|
79
|
+
<span class="persona-tag">{shownLabel}</span>
|
|
50
80
|
</span>
|
|
51
|
-
{#if meta ||
|
|
81
|
+
{#if meta || menuItems.length || onTogglePin || speakable}
|
|
52
82
|
<span class="persona-head-right">
|
|
53
83
|
{#if meta}<span class="persona-meta">{meta}</span>{/if}
|
|
54
84
|
{#if speakable}
|
|
55
85
|
<button
|
|
56
86
|
type="button"
|
|
57
87
|
class="persona-speak"
|
|
58
|
-
class:
|
|
59
|
-
title={
|
|
60
|
-
aria-label={
|
|
61
|
-
aria-pressed={speaking}
|
|
88
|
+
class:active={speech !== "idle"}
|
|
89
|
+
title={speakTitle}
|
|
90
|
+
aria-label={speakTitle}
|
|
62
91
|
onclick={toggleSpeak}
|
|
63
|
-
>{
|
|
92
|
+
>{speech === "playing" ? "⏸\uFE0E" : "▶\uFE0E"}</button>
|
|
93
|
+
{#if speech !== "idle"}
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
class="persona-speak"
|
|
97
|
+
title="Stop reading"
|
|
98
|
+
aria-label="Stop reading"
|
|
99
|
+
onclick={() => speechRegistry.stop()}
|
|
100
|
+
>⏹︎</button>
|
|
101
|
+
{/if}
|
|
64
102
|
{/if}
|
|
65
|
-
{#if
|
|
66
|
-
<LeafActionMenu items={
|
|
103
|
+
{#if menuItems.length || onTogglePin}
|
|
104
|
+
<LeafActionMenu items={menuItems} {pinned} {onTogglePin} {pinDisplay} />
|
|
67
105
|
{/if}
|
|
68
106
|
</span>
|
|
69
107
|
{/if}
|
|
70
108
|
</div>
|
|
71
|
-
{#if children}
|
|
109
|
+
{#if children}
|
|
110
|
+
<div class="persona-body" bind:this={bodyEl}>
|
|
111
|
+
{#if shownRetold}<p class="persona-retold">{shownRetold.text}</p>{:else}{@render children()}{/if}
|
|
112
|
+
</div>
|
|
113
|
+
{/if}
|
|
72
114
|
</div>
|
|
73
115
|
|
|
74
116
|
<style>
|
|
117
|
+
.persona-retold { margin: 0; white-space: pre-wrap; }
|
|
75
118
|
.persona-speak {
|
|
76
119
|
flex-shrink: 0; border: none; background: none; cursor: pointer;
|
|
77
120
|
font-size: 0.95rem; line-height: 1; padding: 0.3rem; border-radius: 6px; color: var(--muted);
|
|
78
121
|
}
|
|
79
122
|
.persona-speak:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--fg); }
|
|
80
|
-
.persona-speak.
|
|
123
|
+
.persona-speak.active { color: var(--accent); }
|
|
81
124
|
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { speechRegistry } from "./speech-registry.svelte";
|
|
3
|
+
|
|
4
|
+
// The global now-playing pill. Reading keeps going after its bubble unmounts (tab or leaf switch),
|
|
5
|
+
// so this is the control that is always reachable. It renders nothing while idle.
|
|
6
|
+
const now = $derived(speechRegistry.current());
|
|
7
|
+
const playing = $derived(now.status === "playing");
|
|
8
|
+
|
|
9
|
+
function reveal() {
|
|
10
|
+
document.querySelector(`[data-speech-id="${now.id}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
{#if now.status !== "idle"}
|
|
15
|
+
<div class="speech-controls" role="region" aria-label="Read aloud">
|
|
16
|
+
<button type="button" class="sc-label" title="Show what is being read" onclick={reveal}>{now.label}</button>
|
|
17
|
+
<span class="sc-progress" aria-label="Sentence {now.index + 1} of {now.total}">{now.index + 1} / {now.total}</span>
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
class="sc-btn"
|
|
21
|
+
title={playing ? "Pause reading" : "Resume reading"}
|
|
22
|
+
aria-label={playing ? "Pause reading" : "Resume reading"}
|
|
23
|
+
onclick={() => (playing ? speechRegistry.pause() : speechRegistry.resume())}
|
|
24
|
+
>{playing ? "⏸︎" : "▶︎"}</button>
|
|
25
|
+
<button type="button" class="sc-btn" title="Stop reading" aria-label="Stop reading" onclick={() => speechRegistry.stop()}>⏹︎</button>
|
|
26
|
+
</div>
|
|
27
|
+
{/if}
|
|
28
|
+
|
|
29
|
+
<style>
|
|
30
|
+
.speech-controls {
|
|
31
|
+
position: fixed; bottom: 1rem; right: 1rem; z-index: 40;
|
|
32
|
+
display: flex; align-items: center; gap: 0.35rem; max-width: calc(100vw - 2rem);
|
|
33
|
+
padding: 0.3rem 0.4rem 0.3rem 0.8rem; border-radius: 999px;
|
|
34
|
+
background: var(--panel); color: var(--fg); border: 1px solid var(--border);
|
|
35
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); font-size: 0.85rem;
|
|
36
|
+
}
|
|
37
|
+
.sc-label {
|
|
38
|
+
border: none; background: none; color: inherit; cursor: pointer; padding: 0;
|
|
39
|
+
font: inherit; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
.sc-progress { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
42
|
+
.sc-btn {
|
|
43
|
+
border: none; background: none; cursor: pointer; color: var(--accent);
|
|
44
|
+
font-size: 0.95rem; line-height: 1; padding: 0.35rem; border-radius: 999px;
|
|
45
|
+
}
|
|
46
|
+
.sc-btn:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
47
|
+
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinytars/frame",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "Domain-neutral Svelte app-shell: session/auth controllers, account chrome, and menu/card/modal primitives built on @tinytars/vault.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -70,6 +70,8 @@
|
|
|
70
70
|
"./SidebarGroupList.svelte": "./SidebarGroupList.svelte",
|
|
71
71
|
"./SidebarLeafList.svelte": "./SidebarLeafList.svelte",
|
|
72
72
|
"./speech-registry.svelte": "./speech-registry.svelte.ts",
|
|
73
|
+
"./retell-registry.svelte": "./retell-registry.svelte.ts",
|
|
74
|
+
"./SpeechControls.svelte": "./SpeechControls.svelte",
|
|
73
75
|
"./support-access.svelte": "./support-access.svelte.ts",
|
|
74
76
|
"./theme.css": "./theme.css",
|
|
75
77
|
"./time-ago": "./time-ago.ts",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// An app may offer a second telling of any assistant bubble (e.g. the same content in another voice).
|
|
2
|
+
// PersonaBubble adds a menu item for it while one is configured; this package knows nothing of what
|
|
3
|
+
// the retelling says, only its label and the voice it is read aloud in.
|
|
4
|
+
export interface Retell {
|
|
5
|
+
label: string;
|
|
6
|
+
voice?: string;
|
|
7
|
+
retell(text: string): Promise<string | null>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const state = $state<{ current: Retell | null }>({ current: null });
|
|
11
|
+
|
|
12
|
+
export function configureRetell(r: Retell | null): void {
|
|
13
|
+
state.current = r;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function currentRetell(): Retell | null {
|
|
17
|
+
return state.current;
|
|
18
|
+
}
|
|
@@ -1,50 +1,189 @@
|
|
|
1
|
-
// The one shared
|
|
2
|
-
//
|
|
3
|
-
// currently-speaking bubble's own button again stops it (toggle) — the same "only one thing
|
|
4
|
-
// active" convention a popover-heavy UI wants for this too.
|
|
1
|
+
// The one shared read-aloud player. It mirrors menu-registry.svelte.ts's singleton shape: playing one
|
|
2
|
+
// thing stops whatever else was playing.
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
4
|
+
// Text is spoken one chunk (roughly one sentence) at a time, and each chunk's `end` starts the next.
|
|
5
|
+
// Chromium silently cuts off a single long utterance after ~15s, and chunking is also what makes
|
|
6
|
+
// progress and resume possible. Pause is cancel() plus a remembered index, not
|
|
7
|
+
// speechSynthesis.pause(), which is a no-op or broken on Android Chrome and some Linux voices. Resume
|
|
8
|
+
// restarts the current chunk.
|
|
9
|
+
//
|
|
10
|
+
// The engine finishes or fails utterances on its own. cancel()'s `end` also arrives as a separate
|
|
11
|
+
// task, after the call that caused it. So every utterance captures the `generation` it was spoken
|
|
12
|
+
// under. Each pause, stop, or new play bumps that counter, which makes a late event from a
|
|
13
|
+
// superseded chunk a no-op.
|
|
14
|
+
//
|
|
15
|
+
// An app may configure a SpeechEngine: each chunk is then synthesized to audio (a neural voice) and
|
|
16
|
+
// played through an HTMLAudioElement, the next chunk prefetched while the current one plays. If the
|
|
17
|
+
// engine or playback fails, the rest of that playback falls back to the browser voice.
|
|
18
|
+
export type SpeechStatus = "idle" | "playing" | "paused";
|
|
19
|
+
|
|
20
|
+
export interface SpeechEngine {
|
|
21
|
+
synthesize(text: string, voice?: string): Promise<Blob>;
|
|
22
|
+
normalize?(text: string): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_CHUNK = 200;
|
|
26
|
+
|
|
27
|
+
const state = $state({
|
|
28
|
+
id: null as string | null,
|
|
29
|
+
label: "",
|
|
30
|
+
status: "idle" as SpeechStatus,
|
|
31
|
+
chunks: [] as string[],
|
|
32
|
+
index: 0,
|
|
33
|
+
});
|
|
34
|
+
let generation = 0;
|
|
35
|
+
let engine: SpeechEngine | null = null;
|
|
36
|
+
let voice: string | undefined;
|
|
37
|
+
let neural = false;
|
|
38
|
+
let audio: HTMLAudioElement | null = null;
|
|
39
|
+
let clips = new Map<number, Promise<string>>();
|
|
40
|
+
|
|
41
|
+
export function configureSpeech(e: SpeechEngine | null): void {
|
|
42
|
+
engine = e;
|
|
43
|
+
}
|
|
11
44
|
|
|
12
45
|
function synth(): SpeechSynthesis | undefined {
|
|
13
46
|
return typeof window !== "undefined" ? window.speechSynthesis : undefined;
|
|
14
47
|
}
|
|
15
48
|
|
|
16
49
|
export function isSpeechSupported(): boolean {
|
|
17
|
-
return !!synth();
|
|
50
|
+
return !!engine || !!synth();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pack(parts: string[]): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
let buf = "";
|
|
56
|
+
for (const p of parts) {
|
|
57
|
+
const next = buf ? `${buf} ${p}` : p;
|
|
58
|
+
if (buf && next.length > MAX_CHUNK) {
|
|
59
|
+
out.push(buf);
|
|
60
|
+
buf = p;
|
|
61
|
+
} else {
|
|
62
|
+
buf = next;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (buf) out.push(buf);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function speechChunks(text: string): string[] {
|
|
70
|
+
const sentences = Array.from(new Intl.Segmenter(undefined, { granularity: "sentence" }).segment(text), (s) => s.segment.trim())
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
return pack(sentences.flatMap((s) => (s.length > MAX_CHUNK ? pack(s.split(/\s+/)) : [s])));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function halt() {
|
|
76
|
+
generation++;
|
|
77
|
+
synth()?.cancel();
|
|
78
|
+
audio?.pause();
|
|
79
|
+
audio = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function releaseClips() {
|
|
83
|
+
for (const p of clips.values()) p.then((url) => URL.revokeObjectURL(url), () => {});
|
|
84
|
+
clips = new Map();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function reset() {
|
|
88
|
+
releaseClips();
|
|
89
|
+
neural = false;
|
|
90
|
+
state.id = null;
|
|
91
|
+
state.label = "";
|
|
92
|
+
state.status = "idle";
|
|
93
|
+
state.chunks = [];
|
|
94
|
+
state.index = 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function advance(gen: number) {
|
|
98
|
+
if (gen !== generation) return;
|
|
99
|
+
if (state.index + 1 < state.chunks.length) {
|
|
100
|
+
state.index++;
|
|
101
|
+
speakCurrent();
|
|
102
|
+
} else {
|
|
103
|
+
reset();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function speakCurrent() {
|
|
108
|
+
const gen = ++generation;
|
|
109
|
+
if (neural) speakNeural(gen);
|
|
110
|
+
else speakBrowser(gen);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function speakBrowser(gen: number) {
|
|
114
|
+
const utterance = new SpeechSynthesisUtterance(state.chunks[state.index]);
|
|
115
|
+
utterance.onend = () => advance(gen);
|
|
116
|
+
utterance.onerror = () => { if (gen === generation) reset(); };
|
|
117
|
+
synth()!.speak(utterance);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clip(i: number): Promise<string> {
|
|
121
|
+
let p = clips.get(i);
|
|
122
|
+
if (!p) {
|
|
123
|
+
p = engine!.synthesize(state.chunks[i], voice).then((blob) => URL.createObjectURL(blob));
|
|
124
|
+
clips.set(i, p);
|
|
125
|
+
}
|
|
126
|
+
return p;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function speakNeural(gen: number) {
|
|
130
|
+
const i = state.index;
|
|
131
|
+
clip(i).then((url) => {
|
|
132
|
+
if (gen !== generation) return;
|
|
133
|
+
const a = new Audio(url);
|
|
134
|
+
audio = a;
|
|
135
|
+
a.onended = () => advance(gen);
|
|
136
|
+
a.onerror = () => fallBack(gen);
|
|
137
|
+
a.play().catch(() => fallBack(gen));
|
|
138
|
+
if (i + 1 < state.chunks.length) clip(i + 1).catch(() => {});
|
|
139
|
+
}, () => fallBack(gen));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fallBack(gen: number) {
|
|
143
|
+
if (gen !== generation) return;
|
|
144
|
+
neural = false;
|
|
145
|
+
audio = null;
|
|
146
|
+
if (synth()) speakBrowser(gen);
|
|
147
|
+
else reset();
|
|
18
148
|
}
|
|
19
149
|
|
|
20
150
|
export const speechRegistry = {
|
|
21
|
-
|
|
22
|
-
return state.
|
|
151
|
+
statusOf(id: string): SpeechStatus {
|
|
152
|
+
return state.id === id ? state.status : "idle";
|
|
23
153
|
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
state.
|
|
44
|
-
|
|
154
|
+
current() {
|
|
155
|
+
return { id: state.id, label: state.label, status: state.status, index: state.index, total: state.chunks.length };
|
|
156
|
+
},
|
|
157
|
+
play(id: string, text: string, label: string, withVoice?: string): void {
|
|
158
|
+
const chunks = speechChunks(engine?.normalize ? engine.normalize(text) : text);
|
|
159
|
+
if (!isSpeechSupported() || !chunks.length) return;
|
|
160
|
+
halt();
|
|
161
|
+
releaseClips();
|
|
162
|
+
voice = withVoice;
|
|
163
|
+
neural = !!engine;
|
|
164
|
+
Object.assign(state, { id, label, status: "playing", chunks, index: 0 });
|
|
165
|
+
speakCurrent();
|
|
166
|
+
},
|
|
167
|
+
pause(): void {
|
|
168
|
+
if (state.status !== "playing") return;
|
|
169
|
+
halt();
|
|
170
|
+
state.status = "paused";
|
|
171
|
+
},
|
|
172
|
+
resume(): void {
|
|
173
|
+
if (state.status !== "paused" || !isSpeechSupported()) return;
|
|
174
|
+
state.status = "playing";
|
|
175
|
+
speakCurrent();
|
|
176
|
+
},
|
|
177
|
+
toggle(id: string, text: string, label: string, withVoice?: string): void {
|
|
178
|
+
if (state.id !== id) this.play(id, text, label, withVoice);
|
|
179
|
+
else if (state.status === "playing") this.pause();
|
|
180
|
+
else this.resume();
|
|
45
181
|
},
|
|
46
182
|
stop(): void {
|
|
47
|
-
|
|
48
|
-
|
|
183
|
+
halt();
|
|
184
|
+
reset();
|
|
49
185
|
},
|
|
50
186
|
};
|
|
187
|
+
|
|
188
|
+
// Nothing keeps speaking into a page that is being navigated away from or put in the bfcache.
|
|
189
|
+
if (typeof window !== "undefined") window.addEventListener("pagehide", () => speechRegistry.stop());
|