agello 0.4.3 → 0.4.4

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.
@@ -56,6 +56,8 @@
56
56
  // el.clearHistory() clear saved chat for the current pane
57
57
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
58
58
 
59
+ import { PresentationMedia, DEFAULT_VOICE } from "./presentation-media.js";
60
+
59
61
  const SCRIPT_ORIGIN = new URL(import.meta.url).origin;
60
62
 
61
63
  let mdPromise;
@@ -81,6 +83,28 @@ const SCREEN_REASONS = {
81
83
  tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
82
84
  no_active_tab: "브라우저에 활성 탭 없음",
83
85
  };
86
+ const MEDIA_ERRORS = {
87
+ tts_key_required: "Typecast API 키를 적용한 뒤 TTS를 켜 주세요.",
88
+ tts_invalid_key: "Typecast API 키가 올바르지 않아요. 키를 확인한 뒤 다시 재생해 주세요.",
89
+ tts_local_key_unavailable: "로컬 키를 찾지 못했어요. ../yt-outlier/.env의 TYPECAST_API_KEY를 확인하거나 키를 직접 입력해 주세요.",
90
+ tts_driver_disconnected: "음성을 재생하던 화면의 연결이 끊겼어요. 이 화면에서 TTS를 켠 뒤 다시 재생해 주세요.",
91
+ tts_prepare_timeout: "음성 생성 시간이 초과됐어요. 연결을 확인하고 다시 재생해 주세요.",
92
+ tts_start_timeout: "음성이 시작되지 않았어요. TTS 켜기로 음성 재생을 허용한 뒤 다시 재생해 주세요.",
93
+ tts_end_timeout: "음성 완료를 확인하지 못했어요. 연결을 확인하고 다시 재생해 주세요.",
94
+ tts_playback_error: "음성을 재생하지 못했어요. TTS 켜기로 음성 재생을 허용한 뒤 다시 재생해 주세요.",
95
+ tts_provider_error: "Typecast 요청이 실패했어요. 키·목소리·사용 한도를 확인하고 다시 재생해 주세요.",
96
+ tts_network_error: "Typecast에 연결하지 못했어요. 인터넷 연결을 확인하고 다시 재생해 주세요.",
97
+ tts_text_limit: "대본 한 줄이 너무 길어요. 2,000자 이하로 나눠 주세요.",
98
+ tts_invalid_audio: "올바른 음성을 받지 못했어요. 목소리 설정을 확인하고 다시 재생해 주세요.",
99
+ tts_audio_not_found: "준비된 음성이 만료됐어요. 대본을 다시 재생해 주세요.",
100
+ tts_failed: "TTS 처리에 실패했어요. 설정을 확인하고 다시 재생하거나 TTS를 꺼 주세요.",
101
+ tts_cancelled: "음성 생성이 취소됐어요. 계속하려면 대본을 다시 재생해 주세요.",
102
+ tts_config_superseded: "TTS 설정이 변경됐어요. 현재 설정을 확인하고 다시 적용해 주세요.",
103
+ tts_stopped: "음성 재생이 종료됐어요. 계속하려면 TTS를 켠 뒤 대본을 재생해 주세요.",
104
+ invalid_voice: "목소리 ID가 올바르지 않아요. Typecast 목소리 ID를 확인해 주세요.",
105
+ invalid_viewer: "발표 화면 연결을 확인하지 못했어요. 페이지를 새로 고친 뒤 다시 시도해 주세요.",
106
+ invalid_tts_config: "TTS 설정이 올바르지 않아요. 키와 목소리 ID를 확인해 주세요.",
107
+ };
84
108
  const ACTIONS = {
85
109
  message: "메시지", approve: "승인", reject: "거절",
86
110
  "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", "hand-done": "질문 완료", annotate: "주석",
@@ -175,6 +199,21 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
175
199
  .screen-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; color: var(--_muted);
176
200
  border-bottom: 1px solid var(--_line); min-width: 0; }
177
201
  .screen-bar .url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
202
+ .screen { position: relative; }
203
+ .media-controls { padding: 7px 12px; border-bottom: 1px solid var(--_line); font-size: 12px; background: var(--_panel); z-index: 6; }
204
+ .media-toolbar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
205
+ .media-toolbar button { padding: 3px 9px; border-radius: 7px; font-size: 12px; }
206
+ .media-record[aria-pressed="true"] { color: #c92a2a; border-color: #c92a2a; }
207
+ .media-download { color: var(--_accent); }
208
+ .media-panel { max-width: 420px; display: grid; gap: 8px; padding: 12px 0 4px; }
209
+ .media-panel[hidden], .media-download[hidden] { display: none; }
210
+ .media-panel label { display: grid; gap: 3px; }
211
+ .media-panel input { min-width: 0; width: 100%; padding: 6px 8px; font: inherit; color: var(--_text); background: var(--_bg); border: 1px solid var(--_line); border-radius: 6px; }
212
+ .media-panel .media-actions { display: flex; gap: 6px; flex-wrap: wrap; }
213
+ .media-status { margin: 4px 0 0; color: var(--_muted); overflow-wrap: anywhere; }
214
+ .media-status.error { color: #c92a2a; }
215
+ .media-note { color: var(--_muted); }
216
+ .body.present .media-controls { position: absolute; top: 12px; right: 12px; left: 12px; width: fit-content; max-width: calc(100% - 24px); border: 1px solid var(--_line); border-radius: 10px; box-shadow: 0 4px 16px rgb(0 0 0 / .2); }
178
217
  .live[hidden] { display: none; }
179
218
  .live { flex: none; font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--_line); }
180
219
  .live.on { background: var(--_accent); border-color: var(--_accent); color: #fff; }
@@ -239,6 +278,8 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
239
278
  /* annotate: hover tint, selected outline, request input next to the element */
240
279
  .annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
241
280
  .annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
281
+ .sound-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
282
+ .sound-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
242
283
  .viewport.annot img { cursor: crosshair; }
243
284
  .hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
244
285
  .hl.on { display: block; }
@@ -451,9 +492,22 @@ const TEMPLATE = `
451
492
  </section>
452
493
  <aside class="screen" part="screen">
453
494
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
495
+ <button class="sound-btn" type="button" aria-pressed="false" title="켜면 브라우저 탭 소리가 이 화면에서만 나와요 (에이전트 쪽 브라우저는 음소거)">소리</button>
454
496
  <button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
455
497
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
456
498
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
499
+ <div class="media-controls" aria-label="발표 설정 및 녹화">
500
+ <div class="media-toolbar"><button type="button" class="media-settings" aria-expanded="false">발표 설정</button>
501
+ <button type="button" class="media-play">대본 재생</button><button type="button" class="media-record" aria-pressed="false">녹화 시작</button>
502
+ <span class="media-elapsed"></span><a class="media-download" hidden>영상 저장</a></div>
503
+ <div class="media-panel" hidden role="group" aria-label="TTS 설정">
504
+ <label>Typecast API 키<input class="media-key" type="password" autocomplete="off" placeholder="키는 서버 메모리에만 보관해요"></label>
505
+ <label>목소리 ID<input class="media-voice" type="text" spellcheck="false" value="${DEFAULT_VOICE}"></label>
506
+ <div class="media-actions"><button type="button" class="media-save">키·목소리 적용</button><button type="button" class="media-local">로컬 키 불러오기</button><button type="button" class="media-clear">키 삭제</button></div>
507
+ <button type="button" class="media-tts" aria-pressed="false">TTS 켜기</button>
508
+ <span class="media-note">TTS와 녹화는 각각 선택해요. 발표 화면 이미지와 대본을 녹화하며, TTS 음성은 TTS를 켠 제어 화면의 녹화에만 담겨요. 최대 15분·256MB.</span>
509
+ </div><p class="media-status" role="status" aria-live="polite"></p>
510
+ </div>
457
511
  <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
458
512
  <form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
459
513
  <div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
@@ -479,6 +533,7 @@ class AgentBridge extends HTMLElement {
479
533
  #root = this.attachShadow({ mode: "open" });
480
534
  #es = null;
481
535
  #screenEs = null;
536
+ #sound = null; // page audio: { ctx, es, next: Map<stream id, end time> }
482
537
  #ws = null; // /input: control input and annotate inspect queries
483
538
  #control = false;
484
539
  #annot = false;
@@ -504,6 +559,10 @@ class AgentBridge extends HTMLElement {
504
559
  #historyKey = null;
505
560
  #history = [];
506
561
  #present = { on: false };
562
+ #viewerId = crypto.randomUUID();
563
+ #media;
564
+ #mediaBusy = false;
565
+ #mediaConnection = 0;
507
566
  static HISTORY_LIMIT = 500;
508
567
  static CAPTION_MS = 10000; // presentation bubble lifetime
509
568
  static EVENT_MS = 5000; // system event bubble lifetime
@@ -522,7 +581,7 @@ class AgentBridge extends HTMLElement {
522
581
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
523
582
  busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
524
583
  askTitle: q(".ask-title"), askDone: q(".ask-done"),
525
- annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
584
+ annotBtn: q(".annot-btn"), soundBtn: q(".sound-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
526
585
  note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
527
586
  };
528
587
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
@@ -536,11 +595,13 @@ class AgentBridge extends HTMLElement {
536
595
  loadMarkdown().then((md) => (this.#md = md));
537
596
  this.#bindControl();
538
597
  this.#bindAnnotate();
598
+ this.$.soundBtn.addEventListener("click", () => this.#setSound(!this.#sound));
539
599
  this.#bindHand();
540
600
  q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
541
601
  q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
542
602
  this.#bindPanes();
543
603
  this.#bindImages();
604
+ this.#bindMedia();
544
605
  }
545
606
 
546
607
  get server() {
@@ -553,17 +614,21 @@ class AgentBridge extends HTMLElement {
553
614
  this.#syncScreen();
554
615
  }
555
616
  disconnectedCallback() {
617
+ this.#mediaConnection++;
618
+ this.#media.reset(this.server, true);
619
+ this.$.captions.replaceChildren();
556
620
  this.#showTerminal(false);
557
621
  this.#es?.close();
558
622
  this.#es = null;
559
623
  this.#screenEs?.close();
560
624
  this.#screenEs = null;
625
+ this.#setSound(false);
561
626
  this.#setControl(false);
562
627
  this.#setAnnotate(false);
563
628
  }
564
629
  attributeChangedCallback(name) {
565
630
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
566
- if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
631
+ if (name === "server" && this.isConnected) { this.#showTerminal(false); this.#openPanes(false); this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#setSound(false); this.#syncScreen(); }
567
632
  if (name === "screen" && this.isConnected) this.#syncScreen();
568
633
  }
569
634
 
@@ -875,7 +940,13 @@ class AgentBridge extends HTMLElement {
875
940
 
876
941
  #connect() {
877
942
  this.#es?.close();
878
- const es = (this.#es = new EventSource(`${this.server}/events`));
943
+ this.#media.reset(this.server);
944
+ this.$.captions.replaceChildren();
945
+ const version = ++this.#mediaConnection;
946
+ const es = (this.#es = new EventSource(`${this.server}/events?viewer=${encodeURIComponent(this.#viewerId)}`));
947
+ this.#media.request("/tts").then((config) => {
948
+ if (version === this.#mediaConnection) this.#media.setConfig(config);
949
+ }).catch((error) => { if (version === this.#mediaConnection) this.#media.report(error); });
879
950
  const on = (name, fn) => es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
880
951
  on("hello", (d) => {
881
952
  if (!d.transcript) this.$.hint.textContent = "대화 기록 파일 없음: 메시지 표시 불가";
@@ -885,10 +956,15 @@ class AgentBridge extends HTMLElement {
885
956
  on("unqueued", ({ id }) => { this.#unqueue(id); this.#emit("agent-queue", { phase: "unqueued", id }); });
886
957
  on("status", (s) => this.#renderStatus(s));
887
958
  on("message", (m) => {
888
- this.#addMessage(m);
889
- this.#saveMessage(m);
959
+ // Joining during speech replays its caption snapshot, which may already
960
+ // be in this pane's saved history. Keep the live caption without a duplicate row.
961
+ const replayedNarration = typeof m.narration === "string" && this.#history.some((saved) => saved.narration === m.narration);
962
+ if (!replayedNarration) {
963
+ this.#addMessage(m);
964
+ this.#saveMessage(m);
965
+ }
890
966
  // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
891
- if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
967
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined, typeof m.narration === "string" ? m.narration : null);
892
968
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
893
969
  // a reply to the viewer's question (status polling may miss a short working phase)
894
970
  // (the agent took the question once it is echoed in the transcript; earlier work was the hand-raise turn)
@@ -897,6 +973,8 @@ class AgentBridge extends HTMLElement {
897
973
  this.#emit("agent-message", m);
898
974
  });
899
975
  on("present", (p) => this.#setPresent(p));
976
+ on("tts", (config) => this.#media.setConfig(config));
977
+ on("speech", (event) => { void this.#media.handleSpeech(event); });
900
978
  on("tool_start", (t) => {
901
979
  this.#tools.set(t.id, t);
902
980
  this.#renderActivity();
@@ -913,12 +991,17 @@ class AgentBridge extends HTMLElement {
913
991
  this.#setToolState(t.id, t.isError ? "error" : "done");
914
992
  this.#emit("agent-tool", { phase: "end", ...t });
915
993
  });
916
- es.onerror = () => { this.#renderStatus(null); this.#setPresent({ on: false }); };
994
+ es.onerror = () => {
995
+ this.#mediaConnection++;
996
+ this.#media.reset(this.server);
997
+ this.#renderStatus(null); this.#setPresent({ on: false });
998
+ this.#media.report("서버 연결이 끊겨 음성과 녹화를 종료했어요. 다시 연결한 뒤 TTS를 켜 주세요.");
999
+ };
917
1000
  }
918
1001
 
919
1002
  #syncScreen() {
920
1003
  const want = this.hasAttribute("screen") || this.#present.on;
921
- if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
1004
+ if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setSound(false); this.#setControl(false); this.#setAnnotate(false); return; }
922
1005
  if (this.#screenEs) return;
923
1006
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
924
1007
  es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
@@ -932,12 +1015,149 @@ class AgentBridge extends HTMLElement {
932
1015
  es.onerror = () => this.#renderScreenMeta({ connected: false });
933
1016
  }
934
1017
 
1018
+ // ---------- page audio ----------
1019
+ // On: the relayed tab's audio plays here and is muted in the agent's
1020
+ // browser (while any viewer has it on). The click creates the AudioContext,
1021
+ // which is what the browser's autoplay policy needs.
1022
+ #setSound(on) {
1023
+ if (!on) {
1024
+ if (!this.#sound) return;
1025
+ this.#sound.es.close();
1026
+ this.#sound.ctx.close().catch(() => {});
1027
+ this.#sound = null;
1028
+ } else {
1029
+ if (this.#sound) return;
1030
+ const ctx = new AudioContext();
1031
+ ctx.resume().catch(() => {});
1032
+ const es = new EventSource(`${this.server}/screen/audio`);
1033
+ const s = (this.#sound = { ctx, es, next: new Map() });
1034
+ es.addEventListener("pcm", (e) => this.#playPcm(s, JSON.parse(e.data)));
1035
+ }
1036
+ this.$.soundBtn.setAttribute("aria-pressed", String(!!this.#sound));
1037
+ }
1038
+
1039
+ // One chunk: 16-bit little-endian stereo. Chunks of a stream are queued
1040
+ // back to back; a gap or a backlog over 1s restarts with a 150ms buffer.
1041
+ #playPcm(s, { c, r, d }) {
1042
+ if (this.#sound !== s) return;
1043
+ const bin = atob(d);
1044
+ const pcm = new Int16Array(bin.length >> 1);
1045
+ for (let i = 0; i < pcm.length; i++) pcm[i] = (bin.charCodeAt(2 * i) | (bin.charCodeAt(2 * i + 1) << 8)) << 16 >> 16;
1046
+ const n = pcm.length >> 1;
1047
+ if (!n) return;
1048
+ const buf = s.ctx.createBuffer(2, n, r);
1049
+ const L = buf.getChannelData(0), R = buf.getChannelData(1);
1050
+ for (let i = 0; i < n; i++) { L[i] = pcm[2 * i] / 32768; R[i] = pcm[2 * i + 1] / 32768; }
1051
+ const src = s.ctx.createBufferSource();
1052
+ src.buffer = buf;
1053
+ src.connect(s.ctx.destination);
1054
+ const now = s.ctx.currentTime;
1055
+ let at = s.next.get(c) ?? 0;
1056
+ if (at < now + 0.02 || at > now + 1) at = now + 0.15;
1057
+ src.start(at);
1058
+ s.next.set(c, at + buf.duration);
1059
+ }
1060
+
935
1061
  // ---------- presentation mode ----------
936
1062
 
1063
+ #bindMedia() {
1064
+ const q = (selector) => this.#root.querySelector(selector);
1065
+ this.#media = new PresentationMedia({
1066
+ viewerId: this.#viewerId, image: this.$.img,
1067
+ captions: () => [...this.$.captions.children].filter((el) => !el.classList.contains("out")).map((el) => ({ text: el.textContent, kind: el.__captionKind || "agent" })),
1068
+ onChange: () => this.#renderMedia(),
1069
+ onSpeech: ({ id, phase }) => {
1070
+ if (["ended", "cancel", "error"].includes(phase)) {
1071
+ for (const el of this.$.captions.children) if (el.dataset.narration === id) el.__leave?.();
1072
+ }
1073
+ },
1074
+ });
1075
+ this.$.img.addEventListener("load", () => this.#media.flushRecordingFrame());
1076
+ q(".media-settings").addEventListener("click", () => {
1077
+ const panel = q(".media-panel");
1078
+ panel.hidden = !panel.hidden;
1079
+ q(".media-settings").setAttribute("aria-expanded", String(!panel.hidden));
1080
+ if (!panel.hidden) q(".media-key").focus();
1081
+ });
1082
+ const run = async (fn) => {
1083
+ if (this.#mediaBusy) return;
1084
+ this.#mediaBusy = true;
1085
+ this.#renderMedia();
1086
+ try { await fn(); } catch (error) { this.#media.report(error); }
1087
+ finally { this.#mediaBusy = false; this.#renderMedia(); }
1088
+ };
1089
+ q(".media-save").addEventListener("click", () => {
1090
+ const input = q(".media-key");
1091
+ const apiKey = input.value.trim();
1092
+ input.value = "";
1093
+ void run(() => this.#media.configure({ ...(apiKey ? { apiKey } : {}), voiceId: q(".media-voice").value.trim() }));
1094
+ });
1095
+ q(".media-local").addEventListener("click", () => { q(".media-key").value = ""; void run(() => this.#media.configure({ useLocalKey: true, voiceId: q(".media-voice").value.trim() })); });
1096
+ q(".media-clear").addEventListener("click", () => { q(".media-key").value = ""; void run(() => this.#media.configure({ clearKey: true })); });
1097
+ q(".media-tts").addEventListener("click", () => {
1098
+ const enabled = !(this.#media.config.enabled && this.#media.config.driverId === this.#viewerId);
1099
+ // Invoke resume while this click still holds browser user activation.
1100
+ void run(async () => {
1101
+ if (enabled) await this.#media.unlock();
1102
+ await this.#media.configure({ enabled, voiceId: q(".media-voice").value.trim() });
1103
+ });
1104
+ });
1105
+ q(".media-play").addEventListener("click", () => {
1106
+ const cmd = this.#present.player?.state === "playing" ? "pause" : "resume";
1107
+ if (cmd === "pause") this.#media.pauseSpeech();
1108
+ else this.#media.resumeSpeech();
1109
+ void run(async () => {
1110
+ if (cmd === "resume" && this.#present.player?.state === "done") await this.#media.request("/player", { cmd: "goto", at: "1" });
1111
+ const data = await this.#media.request("/player", { cmd });
1112
+ this.#present.player = data.player;
1113
+ });
1114
+ });
1115
+ q(".media-record").addEventListener("click", () => {
1116
+ try {
1117
+ if (this.#media.recording) this.#media.stopRecording();
1118
+ else this.#media.startRecording();
1119
+ } catch (error) { this.#media.report(error); }
1120
+ });
1121
+ this.#renderMedia();
1122
+ }
1123
+
1124
+ #renderMedia() {
1125
+ if (!this.#media) return;
1126
+ const q = (selector) => this.#root.querySelector(selector);
1127
+ const media = this.#media, config = media.config;
1128
+ const recording = media.recording;
1129
+ const own = config.enabled && config.driverId === this.#viewerId;
1130
+ const tts = q(".media-tts");
1131
+ tts.textContent = own ? "TTS 끄기" : config.enabled ? "이 화면에서 TTS 켜기" : "TTS 켜기";
1132
+ tts.setAttribute("aria-pressed", String(own));
1133
+ for (const selector of [".media-tts", ".media-save", ".media-local", ".media-clear", ".media-voice"]) q(selector).disabled = this.#mediaBusy || !!recording;
1134
+ const play = q(".media-play");
1135
+ play.textContent = this.#present.player?.state === "playing" ? "일시정지" : this.#present.player?.state === "done" ? "다시 재생" : "대본 재생";
1136
+ play.disabled = this.#mediaBusy || !this.#present.player?.steps;
1137
+ const record = q(".media-record");
1138
+ record.textContent = recording?.stopping ? "영상 저장 중…" : recording ? "녹화 종료" : "녹화 시작";
1139
+ record.setAttribute("aria-pressed", String(!!recording));
1140
+ record.disabled = !!recording?.stopping;
1141
+ const seconds = recording ? Math.floor((performance.now() - recording.started) / 1000) : 0;
1142
+ q(".media-elapsed").textContent = recording ? `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}` : "";
1143
+ const link = q(".media-download");
1144
+ link.hidden = !media.download;
1145
+ if (media.download) { link.href = media.download.url; link.download = media.download.name; }
1146
+ else { link.removeAttribute("href"); link.removeAttribute("download"); }
1147
+ const status = q(".media-status");
1148
+ status.classList.toggle("error", !!media.error);
1149
+ const errorText = Object.hasOwn(MEDIA_ERRORS, media.error) ? MEDIA_ERRORS[media.error] : media.error;
1150
+ status.textContent = errorText || (own ? "TTS ON · 이 화면에서 음성 재생" : config.enabled ? "TTS ON · 다른 화면에서 음성 재생" : config.configured ? "TTS OFF · API 키 준비됨" : "TTS OFF · API 키 미설정");
1151
+ }
1152
+
937
1153
  #setPresent(p) {
938
1154
  const on = !!p?.on;
939
1155
  const was = this.#present.on;
940
1156
  this.#present = p ?? { on: false };
1157
+ this.#media.observePlayer(p?.player?.state);
1158
+ if (!on) this.#media.cancelSpeech();
1159
+ if (!on) this.#media.stopRecording();
1160
+ this.#renderMedia();
941
1161
  this.#emit("agent-present", this.#present);
942
1162
  this.#renderBusy();
943
1163
  if (on === was) return;
@@ -1009,6 +1229,7 @@ class AgentBridge extends HTMLElement {
1009
1229
 
1010
1230
  // Raise: open the input and tell the agent to pause. Failures only show a hint; asking still works.
1011
1231
  async #raiseHand() {
1232
+ this.#media.pauseSpeech();
1012
1233
  this.#event("손들기 확인 · 발표 일시정지");
1013
1234
  this.#qa = "raised";
1014
1235
  this.#openAsk("raised");
@@ -1078,22 +1299,26 @@ class AgentBridge extends HTMLElement {
1078
1299
 
1079
1300
  // Agent reply as a short-lived bubble over the presented screen.
1080
1301
  // kind "user": the viewer's own question (plain text, accent colour).
1081
- #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
1302
+ #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS, narration = null) {
1082
1303
  if (!text?.trim()) return;
1083
1304
  const el = document.createElement("div");
1084
1305
  el.className = `caption ${kind}`;
1085
1306
  el.part = "caption";
1307
+ if (narration) el.dataset.narration = narration;
1308
+ el.__captionKind = kind;
1086
1309
  if (kind === "user" || kind === "event") { el.textContent = text; el.classList.add("plain"); }
1087
1310
  else if (this.#md) el.innerHTML = this.#md(text);
1088
1311
  else { el.textContent = text; el.classList.add("plain"); }
1089
1312
  const box = this.$.captions;
1090
1313
  box.append(el);
1314
+ this.#media.flushRecordingFrame();
1091
1315
  const leave = () => {
1092
1316
  if (el.classList.contains("out")) return;
1093
1317
  el.classList.add("out");
1318
+ this.#media.flushRecordingFrame();
1094
1319
  setTimeout(() => el.remove(), 500);
1095
1320
  };
1096
- setTimeout(leave, ms);
1321
+ if (!narration) setTimeout(leave, ms);
1097
1322
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
1098
1323
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
1099
1324
  el.__leave = leave;