skillok meeting
3699ac7db99816d27535e7df271baaa5fb2e7ec3
Перейти к файлу
Код
meeting srcrecording.ts
anc Проверка звука до записи и предупреждение, если дорожка молчит Встреча, которая молча не записалась, — худшее для программы: человек узнаёт об этом после встречи. - «Новая запись»: проверка звука — микрофон и звук компьютера открываются без записи в файл (audio_check_start/stop), две живые полоски «слышно / тишина». Устройство не открылось — причина и где разрешить (на Mac — «Запись экрана и системного звука»). - Во время записи: дорожка минуту тише −60 dBFS — событие silent, сообщение и строка на панели записи; звук вернулся — heard, строка уходит. На паузе тишина не считается; звук компьютера, который в тишине не приходит вовсе, тоже ловится. - Начало записи гасит проверку: устройства не заняты дважды. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 57e443b 1 октября 2026 г.
recording.ts
// Запись: панель с таймером, уровнями и кнопками — одна на главной и на
// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts).

import { api, elapsed, events } from "./api";
import { esc } from "./dom";
import type { Scope } from "./scope";
import { changed, refreshRecording, state, toast } from "./state";

type Track = "me" | "others";

/** Дорожки, которые сейчас давно молчат. Живёт дольше экрана: человек мог
 * уйти в настройки, а предупреждение должно ждать его на панели записи. */
const silent = new Set<Track>();

const SILENT_TEXT: Record<Track, string> = {
  me: "Минуту не слышно вашего микрофона. Он выключен — в системе или в Zoom — или выбран не тот.",
  others:
    "Минуту не слышно собеседников. Если они говорят, а звука нет — звук компьютера не пишется: на Mac разрешите Skillok Meet «Запись экрана и системного звука» в настройках конфиденциальности.",
};

/** Слушать тишину дорожек на всю программу: предупреждение — и сообщением, и на панели. */
export function startSilenceWatch(): void {
  void events.recorder(({ meeting_id, event }) => {
    if (!meeting_id || state.recording?.meeting_id !== meeting_id) return;
    if (event.kind !== "silent" && event.kind !== "heard") return;
    const track: Track = event.data.speaker.kind === "me" ? "me" : "others";
    if (event.kind === "silent") {
      silent.add(track);
      toast(SILENT_TEXT[track]);
    } else {
      silent.delete(track);
    }
    changed();
  });
}

/** Новая запись — старые предупреждения не в счёт. */
export function resetSilence(): void {
  silent.clear();
}

export function recordingPanel(): string {
  const r = state.recording;
  if (!r) return "";
  return `
    <div class="rec" data-rec>
      <p class="rec-state"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
        r.paused ? "Запись на паузе" : r.system ? "Идёт запись: собеседники и вы" : "Идёт запись: только ваш микрофон"
      }</p>
      ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся: ${esc(r.system_error)}</p>` : ""}
      ${[...silent].map((t) => `<p class="rec-warn small" data-silent="${t}">${esc(SILENT_TEXT[t])}</p>`).join("")}
      <p class="rec-timer mono-num" data-timer>${elapsed(r.started_at)}</p>
      <div class="meter" title="Громкость того, что пишется: ваш микрофон и звук компьютера вместе">
        <span class="meter-bar"><span class="meter-fill" data-level></span></span>
      </div>
      <div class="actions">
        <button class="ui-btn" data-rec-stop>Остановить</button>
        <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
      </div>
    </div>`;
}

/** Кнопки панели и живые уровни звука. `onStop` — после остановки. */
export function bindRecording(root: HTMLElement, scope: Scope, onStop: () => void): void {
  root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", async (e) => {
    const b = e.currentTarget as HTMLButtonElement;
    b.disabled = true;
    b.textContent = "Останавливаю…";
    try {
      await api.recorderStop();
      await refreshRecording();
      onStop();
    } catch (err) {
      b.disabled = false;
      toast(err);
    }
  });
  root.querySelector("[data-rec-pause]")?.addEventListener("click", async () => {
    if (!state.recording) return;
    try {
      await api.recorderPause(!state.recording.paused);
      state.recording.paused = !state.recording.paused;
      changed();
    } catch (err) {
      toast(err);
    }
  });
  // Один общий уровень: громче из дорожек за последние полсекунды. Человеку
  // важно одно — запись идёт и звук есть; делить его на «вы» и «собеседники»
  // незачем (решение владельца 01.10.2026).
  const last: Record<string, { dbfs: number; at: number }> = {};
  scope.on(
    events.recorder(({ meeting_id, event }) => {
      // Уровни проверки звука (пустой meeting_id) — не для панели записи.
      if (!meeting_id) return;
      if (event.kind === "level") {
        const now = performance.now();
        last[event.data.speaker.kind] = { dbfs: event.data.dbfs, at: now };
        const dbfs = Math.max(-90, ...Object.values(last).filter((l) => now - l.at < 500).map((l) => l.dbfs));
        const fill = root.querySelector<HTMLElement>("[data-level]");
        if (fill) fill.style.width = `${Math.max(0, Math.min(100, ((dbfs + 60) / 55) * 100))}%`;
      } else if (event.kind === "track_failed") {
        toast(`Дорожка «${event.data.speaker.kind === "me" ? "вы" : "собеседники"}» перестала писаться: ${event.data.error}`);
      }
    }),
  );
  scope.every(1000, () => {
    const t = root.querySelector("[data-timer]");
    if (t && state.recording) t.textContent = elapsed(state.recording.started_at);
  });
}