skillok meeting
57e443b222306e63eaf81d54fae10fa0a4f4ea1a
Перейти к файлу
Код
meeting srcviewsmeeting.ts
anc Ход работы не теряется при уходе с экрана; без «Записать» в шапке и предупреждения - jobs.ts: расшифровка и ответ ИИ живут, пока открыта программа, а не экран. Вернулся — видны процент, пришедшие реплики, текст ответа и его вкладка; кончилось — экран перечитывает запись сам. Раньше экран показывал «Расшифровать», хотя ядро ещё работало. - В меню у записи — метка «расшифровка» / «ИИ», пока идёт работа. - Кнопка «Записать» из шапки убрана (метка идущей записи осталась). - Блок «Текст предупреждения участникам» убран из настроек, экрана «Новая запись» и панели записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> a730a5c 1 октября 2026 г.
623 строки 27.4 КБ
meeting.ts
// Экран записи — рабочее место вокруг записи, как у MacWhisper: сверху
// название и «Копировать», в центре расшифровка по репликам, под ней следующий
// шаг. Пока расшифровки нет — одна карточка с главной кнопкой. Результаты — вкладками:
// «Расшифровка», «Конспект», «Задачи», «Ревью», свои запросы и «Свой запрос».
// Каждая вкладка — раздел заметки note.md; его можно скопировать, поправить,
// сделать заново или удалить.

import {
  api, formatDate, formatDuration, meetingDuration,
  type Command, type MeetingDetail,
} from "../api";
import { $, esc } from "../dom";
import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown";
import { bindRecording, recordingPanel } from "../recording";
import type { Scope } from "../scope";
import * as jobs from "../jobs";
import { refreshNav, setCrumb } from "../nav";
import { copy, onChange, state, toast } from "../state";

const TRANSCRIPT = "Расшифровка";

interface Preset {
  heading: string;
  command: Command;
  /** Что это и зачем — на пустой вкладке. */
  about: string;
  action: string;
  working: string;
}

const PRESETS: Preset[] = [
  {
    heading: "Конспект",
    command: { kind: "summary" },
    about: "О чём говорили, чем закончилось и какие решения приняли — на полстраницы.",
    action: "Сделать конспект",
    working: "ИИ пишет конспект…",
  },
  {
    heading: "Задачи",
    command: { kind: "tasks" },
    about: "Кто что взял на себя и к какому сроку — списком с галочками. И вопросы, оставшиеся без ответа.",
    action: "Выписать задачи",
    working: "ИИ выписывает задачи…",
  },
  {
    heading: "Ревью",
    command: { kind: "review" },
    about: "Взгляд со стороны: чего добились, что осталось открытым, где ушли в сторону.",
    action: "Сделать ревью",
    working: "ИИ пишет ревью…",
  },
];

const CUSTOM_TAB = "+ Свой запрос";

type Step = "audio" | "transcribe" | "result";

function stepOf(d: MeetingDetail): Step {
  if (d.meeting.recording || !d.meeting.audio.length) return "audio";
  if (!d.meeting.transcribed_at) return "transcribe";
  return "result";
}

type Stage = "speech" | "voices";

const STAGE_TITLE: Record<Stage, string> = { speech: "Расшифровываю", voices: "Разделяю по голосам" };

function progressNote(stage: Stage, done: number, total: number): string {
  const what =
    stage === "voices"
      ? `Сравниваю голоса: ${done} из ${total} реплик`
      : total
        ? `Готово ${done} из ${total} кусков`
        : "Готовлю звук…";
  return `${what}. Можно уйти с экрана — расшифровка продолжится.`;
}

/** Реплика расшифровки, как её пишет ядро: «**00:03:21 Вы:** текст» или «**00:03:21** текст». */
const LINE = /^\*\*(\d\d:\d\d:\d\d)(?: ([^*]+?):)?\*\*\s*([\s\S]*)$/;

/**
 * Расшифровка — по репликам: время слева, кто говорит и текст справа, как у
 * MacWhisper. Если человек переписал её по-своему — показываем как Markdown.
 */
function transcriptView(body: string): string {
  const paras = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
  const rows = transcriptRows(paras, true);
  return rows === null ? `<div class="md">${render(body)}</div>` : `<div class="tr">${rows}</div>`;
}

/** Реплики строками таблицы; `null` — если хоть одна не похожа на реплику. */
function transcriptRows(paras: string[], renamable = false): string | null {
  const rows = paras.map((p) => LINE.exec(p));
  if (!rows.length || rows.some((r) => !r)) return paras.length ? null : "";
  return rows
    .map((r) => {
      const [, time, who, text] = r!;
      return `
        <div class="tr-row">
          <span class="tr-time mono-num">${esc(time)}</span>
          <div class="tr-text">
            ${
              !who
                ? ""
                : renamable
                  ? `<button type="button" class="tr-who${who === "Я" ? " is-me" : ""}" data-speaker="${esc(who)}" title="Переименовать «${esc(who)}» во всей записи">${esc(who)}</button>`
                  : `<span class="tr-who${who === "Я" ? " is-me" : ""}">${esc(who)}</span>`
            }
            <p>${esc(text)}</p>
          </div>
        </div>`;
    })
    .join("");
}

export async function renderMeeting(root: HTMLElement, parent: Scope, id: string, wantTab: string | null): Promise<void> {
  const scope = parent.child();
  const [initial, view] = await Promise.all([api.getMeeting(id), api.getSettings()]);
  if (!scope.alive) return;
  const d: MeetingDetail = initial;
  const hasKey = Boolean(view.openrouter_key);

  // Что сейчас идёт над записью (расшифровка или ответ ИИ) — в jobs.ts: оно
  // живёт дольше экрана и видно, даже если уйти и вернуться.
  // Вернулись, пока ИИ пишет ответ, — сразу на его вкладку.
  const running = jobs.jobOf(id);
  let tab = wantTab ?? (running?.kind === "command" ? running.tab : TRANSCRIPT);
  let editing = false;

  const tabs = (): string[] => {
    const have = sections(d.note).map((s) => s.heading || "Заметки");
    const order = [TRANSCRIPT, ...PRESETS.map((p) => p.heading)];
    const extra = have.filter((h) => !order.includes(h));
    return [...order, ...extra, CUSTOM_TAB];
  };

  const section = (heading: string) => sections(d.note).find((s) => (s.heading || "Заметки") === heading);

  // ── Шаг 1: аудио ──────────────────────────────────────────────────────────
  const audioStep = () => {
    if (d.meeting.recording) {
      return `<section class="card">${recordingPanel()}</section>`;
    }
    return `
      <section class="card">
        <h2>В этой записи нет звука</h2>
        <p class="muted">Она осталась от прежней версии программы. Её можно удалить.</p>
      </section>`;
  };

  // ── Шаг 2: расшифровка ────────────────────────────────────────────────────
  const transcribeStep = () => {
    const job = jobs.jobOf(id);
    const jobError = jobs.errorOf(id);
    const dur = formatDuration(meetingDuration(d.meeting));
    if (job?.kind === "transcribe") {
      const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
      return `
        <section class="live">
          <div class="live-head">
            <p class="live-title"><span class="spinner"></span><span data-stage-title>${STAGE_TITLE[job.stage]}${
              job.stage === "speech" && dur ? ` ${esc(dur)} звука` : ""
            }</span>
              <span class="mono-num live-pct" data-pct>${pct}%</span></p>
            <button class="link-btn" data-cancel>Отменить</button>
          </div>
          <div class="progress"><span style="width:${pct}%"></span></div>
          <p class="xs muted" data-progress-note>${progressNote(job.stage, job.done, job.total)}</p>
          <div class="pane"><div class="pane-body"><div class="tr" data-live>${transcriptRows(job.lines) ?? ""}</div>
            <p class="live-wait small muted" data-live-wait ${job.lines.length ? "hidden" : ""}>Текст появится здесь, как только распознается первый кусок.</p></div></div>
        </section>`;
    }
    if (!hasKey) {
      return `
        <section class="card">
          <h2>Подключите ИИ, чтобы расшифровать</h2>
          <p class="muted">Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.</p>
          <a class="ui-btn" href="#/settings">Подключить</a>
        </section>`;
    }
    return `
      <section class="card">
        <h2>Расшифруйте запись</h2>
        <p class="muted">${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${
          meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : ""
        }.</p>
        ${jobError ? `<p class="error">${esc(jobError)}</p>` : ""}
        <button class="ui-btn" data-transcribe>Расшифровать</button>
      </section>`;
  };

  // ── Шаг 3: результат ──
  const nextStep = () => {
    const job = jobs.jobOf(id);
    const todo = PRESETS.filter((p) => !section(p.heading));
    if (!hasKey) return "";
    return `
      <div class="next">
        <span class="next-label">Дальше</span>
        ${todo.map((p) => `<button class="ui-btn ui-btn-sm" data-make="${esc(p.heading)}" ${job ? "disabled" : ""}>${esc(p.heading)}</button>`).join("")}
        <button class="ui-btn ui-btn-sm" data-tab="${esc(CUSTOM_TAB)}">Свой запрос</button>
      </div>`;
  };
  const tabContent = () => {
    const job = jobs.jobOf(id);
    const jobError = jobs.errorOf(id);
    const busyHere = job?.kind === "command" && job.tab === tab;
    const busyElsewhere = job !== null && !busyHere;
    const errorLine = jobError && !job ? `<p class="error">${esc(jobError)}</p>` : "";

    if (busyHere) {
      const preset = PRESETS.find((p) => p.heading === tab);
      return `
        <div class="pane-tools">
          <p class="working small"><span class="spinner"></span>${esc(preset?.working ?? "ИИ отвечает…")}</p>
          <span class="spacer"></span>
          <button class="link-btn" data-cancel>Отменить</button>
        </div>
        <div class="pane-body"><div class="md" data-answer>${job?.kind === "command" && job.answer ? render(job.answer) : `<p class="muted small">Читаю расшифровку…</p>`}</div></div>`;
    }

    if (tab === CUSTOM_TAB) {
      return `
        <form class="pane-empty ask" data-ask>
          <h3>Что сделать по этой встрече?</h3>
          <input class="input" name="text" autocomplete="off" placeholder="Например: напиши письмо клиенту по итогам" aria-label="Свой запрос" ${busyElsewhere || !hasKey ? "disabled" : ""}>
          <p class="small muted">Ещё примеры:
            <button type="button" class="link-btn" data-example>выпиши все цифры и даты</button>,
            <button type="button" class="link-btn" data-example>какие риски прозвучали</button>,
            <button type="button" class="link-btn" data-example>составь повестку следующей встречи</button>.</p>
          ${errorLine}
          ${
            hasKey
              ? `<button class="ui-btn" type="submit" ${busyElsewhere ? "disabled" : ""}>Сделать</button>`
              : `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
          }
        </form>`;
    }

    const s = section(tab);
    const preset = PRESETS.find((p) => p.heading === tab);
    if (!s) {
      return `
        <div class="pane-empty">
          <h3>${esc(tab)} ещё не ${tab === "Задачи" ? "готовы" : tab === "Ревью" ? "готово" : "готов"}</h3>
          <p class="muted">${esc(preset?.about ?? "")}</p>
          ${errorLine}
          ${
            !hasKey
              ? `<a class="ui-btn" href="#/settings">Подключить ИИ</a>`
              : `<button class="ui-btn" data-make="${esc(tab)}" ${busyElsewhere ? "disabled" : ""}>${esc(preset?.action ?? "Сделать")}</button>`
          }
          ${busyElsewhere ? `<p class="small muted">Сначала дождитесь, пока закончится текущая работа.</p>` : ""}
        </div>`;
    }

    if (editing) {
      return `
        <div class="pane-edit">
          <textarea class="section-editor" data-editor spellcheck="true">${esc(sectionBody(s.source))}</textarea>
          <div class="actions">
            <button class="ui-btn" data-edit-save>Сохранить</button>
            <button class="link-btn" data-edit-cancel>Отмена</button>
            <span class="small muted">Markdown: <code>**жирный**</code>, <code>- список</code>, <code>- [ ] задача</code></span>
          </div>
        </div>`;
    }

    const isTranscript = tab === TRANSCRIPT;
    const remakeable = !isTranscript && s.heading !== "";
    const body = sectionBody(s.source);
    return `
      <div class="pane-tools">
        <button class="link-btn" data-edit>Править</button>
        ${isTranscript ? `<span class="xs muted">Имя говорящего — нажмите, чтобы переименовать</span>` : ""}
        ${
          isTranscript && d.voices
            ? `<label class="xs muted speakers-pick">Говорящих
                 <select class="input" data-speakers ${job ? "disabled" : ""}>
                   <option value="">как нашлось</option>
                   ${[1, 2, 3, 4, 5, 6, 7, 8].map((n) => `<option value="${n}">${n}</option>`).join("")}
                 </select></label>`
            : ""
        }
        <span class="spacer"></span>
        ${
          isTranscript
            ? `<button class="link-btn" data-retranscribe ${job ? "disabled" : ""}>Расшифровать заново</button>`
            : remakeable
              ? `<button class="link-btn" data-remake ${job || !hasKey ? "disabled" : ""}>Сделать заново</button>
                 <button class="link-btn link-danger" data-remove>Удалить</button>`
              : ""
        }
      </div>
      ${errorLine}
      <div class="pane-body" data-view>${isTranscript ? transcriptView(body) : `<div class="md">${render(body)}</div>`}</div>
      ${isTranscript ? nextStep() : ""}`;
  };

  const resultStep = () => `
    <section class="result">
      <nav class="tabs" role="tablist" aria-label="Результаты">
        ${tabs()
          .map((t) => {
            const ready = t === CUSTOM_TAB || Boolean(section(t));
            return `<button role="tab" class="tab${t === tab ? " is-active" : ""}${ready ? "" : " is-empty"}${
              t === CUSTOM_TAB ? " tab-custom" : ""
            }" aria-selected="${t === tab}" data-tab="${esc(t)}">${esc(t)}</button>`;
          })
          .join("")}
      </nav>
      <div class="pane">${tabContent()}</div>
    </section>`;

  const draw = () => {
    setCrumb(d.meeting.title);
    const step = stepOf(d);
    if (!tabs().includes(tab)) tab = TRANSCRIPT;
    const meta = [formatDate(d.meeting.created_at), formatDuration(meetingDuration(d.meeting))].filter(Boolean).join(" · ");
    const copyable = step === "result" && !editing && Boolean(section(tab));
    root.innerHTML = `
      <article class="meeting">
        <header class="bar">
          <div class="bar-title">
            <input class="title-input" data-title value="${esc(d.meeting.title)}" aria-label="Название записи" spellcheck="false">
            <p class="xs muted">${esc(meta)}</p>
          </div>
          ${
            copyable
              ? `<div class="bar-tools">
                   <button class="ui-btn ui-btn-sm" data-copy>Копировать</button>
                   <button class="link-btn" data-copy-plain>как текст</button>
                 </div>`
              : ""
          }
        </header>
        ${step === "audio" ? audioStep() : step === "transcribe" ? transcribeStep() : resultStep()}
        <footer class="meeting-foot">
          <button class="link-btn" data-open-folder>Открыть папку с файлами</button>
          <button class="link-btn link-danger" data-delete ${d.meeting.recording ? "hidden" : ""}>Удалить запись</button>
        </footer>
      </article>`;
    bind();
  };

  // ── Действия ──────────────────────────────────────────────────────────────

  const transcribe = () => {
    tab = TRANSCRIPT;
    void jobs.transcribe(id);
  };

  const runCommand = (command: Command, forTab: string) => {
    tab = forTab;
    void jobs.command(id, command, forTab);
  };

  const saveNote = async (note: string) => {
    const before = d.note;
    d.note = note;
    try {
      await api.saveNote(id, note);
    } catch (e) {
      d.note = before;
      toast(e);
      draw();
    }
  };

  const bind = () => {
    const title = $(root, "[data-title]") as HTMLInputElement;
    title.addEventListener("keydown", (e) => {
      if (e.key === "Enter") title.blur();
      if (e.key === "Escape") {
        title.value = d.meeting.title;
        title.blur();
      }
    });
    title.addEventListener("change", async () => {
      try {
        d.meeting = await api.renameMeeting(id, title.value);
        void refreshNav();
      } catch (e) {
        title.value = d.meeting.title;
        toast(e);
      }
    });

    bindRecording(root, scope, async () => {
      d.meeting = (await api.getMeeting(id)).meeting;
      draw();
    });

    root.querySelector("[data-transcribe]")?.addEventListener("click", transcribe);
    root.querySelector("[data-retranscribe]")?.addEventListener("click", async () => {
      if (!confirm("Расшифровать заново? Нынешняя расшифровка и ваши правки в ней заменятся новой.")) return;
      await transcribe();
    });
    root.querySelector("[data-cancel]")?.addEventListener("click", () => api.cancelJob(id));

    root.querySelectorAll<HTMLButtonElement>("[data-tab]").forEach((b) =>
      b.addEventListener("click", () => {
        tab = b.dataset.tab!;
        editing = false;
        jobs.clearError(id);
        draw();
      }),
    );
    root.querySelectorAll<HTMLButtonElement>("[data-make]").forEach((btn) =>
      btn.addEventListener("click", () => {
        const heading = btn.dataset.make!;
        const preset = PRESETS.find((p) => p.heading === heading);
        if (preset) runCommand(preset.command, heading);
      }),
    );
    root.querySelector("[data-remake]")?.addEventListener("click", () => {
      const preset = PRESETS.find((p) => p.heading === tab);
      // Свой запрос повторяется своим же текстом: он и есть заголовок вкладки.
      runCommand(preset ? preset.command : { kind: "custom", text: tab }, tab);
    });
    root.querySelector("[data-remove]")?.addEventListener("click", async () => {
      if (!confirm(`Удалить «${tab}»?`)) return;
      try {
        d.note = await api.removeSection(id, tab);
        if (!PRESETS.some((p) => p.heading === tab)) tab = TRANSCRIPT;
        draw();
      } catch (e) {
        toast(e);
      }
    });

    const ask = root.querySelector<HTMLFormElement>("[data-ask]");
    ask?.querySelectorAll<HTMLButtonElement>("[data-example]").forEach((b) =>
      b.addEventListener("click", () => {
        const input = ask.elements.namedItem("text") as HTMLInputElement;
        input.value = b.textContent ?? "";
        input.focus();
      }),
    );
    ask?.addEventListener("submit", (e) => {
      e.preventDefault();
      const input = ask.elements.namedItem("text") as HTMLInputElement;
      const text = input.value.trim();
      if (!text) {
        input.focus();
        return;
      }
      const heading = text.charAt(0).toUpperCase() + text.slice(1);
      runCommand({ kind: "custom", text }, heading.length > 60 ? heading.slice(0, 60).trimEnd() + "…" : heading);
    });

    const s = section(tab);
    root.querySelector("[data-copy]")?.addEventListener("click", () => s && copy(sectionBody(s.source), `«${tab}» скопировано`));
    root.querySelector("[data-copy-plain]")?.addEventListener("click", () => s && copy(plain(sectionBody(s.source)), `«${tab}» скопировано текстом`));
    root.querySelector("[data-edit]")?.addEventListener("click", () => {
      editing = true;
      draw();
      root.querySelector<HTMLTextAreaElement>("[data-editor]")?.focus();
    });
    root.querySelector("[data-edit-cancel]")?.addEventListener("click", () => {
      editing = false;
      draw();
    });
    root.querySelector("[data-edit-save]")?.addEventListener("click", async () => {
      const body = (root.querySelector("[data-editor]") as HTMLTextAreaElement).value.trim();
      editing = false;
      await saveNote(upsertSection(d.note, tab, `## ${tab}\n\n${body}`));
      draw();
    });

    // Галочки задач — живые: отметка пишется в заметку.
    root.querySelectorAll<HTMLInputElement>("[data-view] input[type=checkbox]").forEach((box, n) => {
      box.disabled = false;
      box.addEventListener("change", async () => {
        const sec = section(tab);
        if (!sec) return;
        const body = toggleTask(sectionBody(sec.source), n);
        await saveNote(upsertSection(d.note, tab, `## ${tab}\n\n${body}`));
      });
    });

    // Имя говорящего — кнопка: нажал, вписал «Ольга», Enter — имя сменилось
    // во всей записи. Esc — отмена.
    root.querySelectorAll<HTMLButtonElement>("[data-speaker]").forEach((btn) =>
      btn.addEventListener("click", () => {
        const from = btn.dataset.speaker!;
        const input = document.createElement("input");
        input.className = "input tr-who-input";
        input.value = from;
        input.setAttribute("aria-label", `Новое имя для «${from}»`);
        btn.replaceWith(input);
        input.focus();
        input.select();
        let done = false;
        const finish = async (apply: boolean) => {
          if (done) return;
          done = true;
          const to = input.value.trim();
          if (!apply || to === from) {
            draw();
            return;
          }
          const problem = speakerNameProblem(to);
          if (problem) {
            toast(problem);
            draw();
            return;
          }
          const r = renameSpeaker(d.note, TRANSCRIPT, from, to);
          await saveNote(r.note);
          draw();
          toast(`«${from}» теперь «${to}» — реплик: ${r.lines}`, "ok");
        };
        input.addEventListener("keydown", (e) => {
          if (e.key === "Enter") void finish(true);
          if (e.key === "Escape") void finish(false);
        });
        input.addEventListener("blur", () => void finish(true));
      }),
    );

    // Сколько говорящих: разбиение пересобирается из сохранённых отпечатков,
    // без новой расшифровки. Правки и имена в расшифровке заменятся.
    root.querySelector<HTMLSelectElement>("[data-speakers]")?.addEventListener("change", async (e) => {
      const select = e.currentTarget as HTMLSelectElement;
      const count = select.value ? Number(select.value) : null;
      const ask = `Разделить расшифровку на ${count ? `${count} говорящих` : "столько говорящих, сколько найдётся"}? Правки и переименования в расшифровке заменятся.`;
      if (!confirm(ask)) {
        select.value = "";
        return;
      }
      try {
        const r = await api.regroupSpeakers(id, count);
        d.note = r.note;
        draw();
        toast(`Говорящих: ${r.voices}`, "ok");
      } catch (err) {
        toast(err);
      }
    });

    root.querySelector("[data-open-folder]")?.addEventListener("click", () => api.openFolder(id).catch(toast));

    root.querySelector("[data-delete]")?.addEventListener("click", async () => {
      if (!confirm(`Удалить «${d.meeting.title}» вместе со звуком и результатами? Это не отменить.`)) return;
      try {
        await api.deleteMeeting(id);
        location.hash = "#/";
      } catch (e) {
        toast(e);
      }
    });
  };

  draw();

  // Ход работы над этой записью: реплики и проценты — точечно, без
  // перерисовки (иначе сбивается прокрутка), конец — перечитать запись.
  let frame = 0;
  const offJobs = jobs.onJobs(async (jobId, e) => {
    if (jobId !== id || !scope.alive) return;
    const job = jobs.jobOf(id);
    if (e.type === "done") {
      const fresh = await api.getMeeting(id);
      if (!scope.alive) return;
      Object.assign(d, fresh);
      if (e.kind === "command" && e.heading) tab = e.heading;
      void refreshNav();
      draw();
      return;
    }
    if (job?.kind === "command") {
      // Новая работа с другой вкладки или первый кусок ответа — перерисовка;
      // дальше ответ дописывается не чаще кадра.
      const el = root.querySelector("[data-answer]");
      if (!el || e.type === "progress") {
        draw();
        return;
      }
      if (frame) return;
      frame = requestAnimationFrame(() => {
        frame = 0;
        const now = jobs.jobOf(id);
        if (now?.kind === "command") el.innerHTML = render(now.answer);
      });
      return;
    }
    if (job?.kind !== "transcribe") return;
    if (!root.querySelector("[data-live]")) {
      draw();
      return;
    }
    if (e.type === "stage") {
      const title = root.querySelector("[data-stage-title]");
      if (title) title.textContent = STAGE_TITLE[job.stage];
    }
    if (e.type === "lines") {
      root.querySelector("[data-live]")?.insertAdjacentHTML("beforeend", transcriptRows(e.lines) ?? "");
      root.querySelector<HTMLElement>("[data-live-wait]")?.setAttribute("hidden", "");
    }
    const { stage, done, total } = job;
    const pct = total ? Math.round((done / total) * 100) : 0;
    const bar = root.querySelector<HTMLElement>(".progress > span");
    const big = root.querySelector("[data-pct]");
    const note = root.querySelector("[data-progress-note]");
    if (bar) bar.style.width = `${pct}%`;
    if (big) big.textContent = `${pct}%`;
    if (note) note.textContent = total || stage === "voices" ? progressNote(stage, done, total) : "В записи не нашлось речи.";
  });
  scope.on(Promise.resolve(offJobs));
  // Запись остановили из шапки или с главной — шаг меняется.
  const off = onChange(async () => {
    if (!scope.alive) return;
    if (d.meeting.recording && state.recording?.meeting_id !== id) {
      d.meeting = (await api.getMeeting(id)).meeting;
      draw();
    } else if (d.meeting.recording) {
      draw();
    }
  });
  scope.on(Promise.resolve(off));
}