skillok meeting
ade9583c661cc1280b544b11b9e57c1c48bba461
Перейти к файлу
Код
meeting srcrecording.ts
anc Разрешения: шаги и кнопка «Открыть настройки» вместо системной ошибки На Mac разрешение «Запись экрана и системного звука» запоминается за конкретной сборкой программы (сборки подписаны ad-hoc): после обновления Skillok Meet в списке включён, а новой версии macOS прав не даёт. - open_privacy: открывает нужный раздел настроек — «Запись экрана и системного звука» или «Микрофон» на macOS, «Микрофон» в Windows. - Проверка звука и панель записи: вместо текста ошибки — что делать, с объяснением ловушки «включено, но от прошлой версии — удалите «−» и разрешите заново», и кнопка в настройки; текст системы — мелко ниже. - CheckView.os. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> ade9583 4 октября 2026 г.
recording.ts
// Запись: панель с таймером, уровнями и кнопками — одна на главной и на
// экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts).

import { api, events, type Marker } from "./api";
import * as jobs from "./jobs";
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 «Запись экрана и системного звука» в настройках конфиденциальности.",
};

const isMac = () => /Mac/i.test(navigator.userAgent);

/**
 * Что делать, если звук компьютера или микрофон не пишется, — с кнопкой,
 * открывающей нужный раздел настроек системы.
 *
 * Главная ловушка macOS: разрешение запоминается за конкретной сборкой
 * программы. После обновления Skillok Meet в списке остаётся включённым, но
 * это разрешение прошлой версии — новой macOS прав не даёт. Лечится одним
 * способом: убрать Skillok Meet из списка и разрешить заново.
 */
export function permissionHelp(track: Track): string {
  if (track === "others") {
    if (!isMac()) return "";
    return `
      <div class="perm-help">
        <p class="small">macOS не даёт записывать звук компьютера. Откройте «Запись экрана и системного звука» и проверьте Skillok Meet:</p>
        <ol class="small">
          <li>Нет в списке или выключен — включите.</li>
          <li><b>Уже включён</b> — значит, разрешение осталось от прошлой версии программы, а новой macOS прав не даёт. Выделите Skillok Meet и удалите кнопкой «−».</li>
          <li>Закройте и снова откройте Skillok Meet, нажмите «Записать встречу» — macOS спросит разрешение, разрешите и ещё раз перезапустите программу.</li>
        </ol>
        <button class="ui-btn ui-btn-sm" type="button" data-open-privacy="screen">Открыть настройки macOS</button>
      </div>`;
  }
  return `
    <div class="perm-help">
      <p class="small">Проверьте, что микрофон подключён и Skillok Meet разрешён доступ к нему.${
        isMac() ? " После обновления программы разрешение тоже может слететь — выключите и включите его снова." : ""
      }</p>
      <button class="ui-btn ui-btn-sm" type="button" data-open-privacy="microphone">Открыть настройки ${isMac() ? "macOS" : "Windows"}</button>
    </div>`;
}

/** Кнопки «Открыть настройки» внутри `root`. */
export function bindPermissionHelp(root: HTMLElement): void {
  root.querySelectorAll<HTMLButtonElement>("[data-open-privacy]").forEach((b) =>
    b.addEventListener("click", () => api.openPrivacy(b.dataset.openPrivacy as "screen" | "microphone").catch(toast)),
  );
}

/** Слушать тишину дорожек на всю программу: предупреждение — и сообщением, и на панели. */
export function startSilenceWatch(): void {
  void events.recorder(({ meeting_id, event }) => {
    if (!meeting_id || state.recording?.meeting_id !== meeting_id) return;
    if (event.kind === "level") {
      onLevel(trackOf(event.data.speaker.kind), event.data.dbfs, event.data.ms);
      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();
  wave.length = 0;
  positionMs = 0;
  heardAt.me = heardAt.others = 0;
  marks = [];
}

/** Последняя минута звука записи: громкость каждые 0,2 с — для живой «волны». */
const WAVE_POINTS = 300;
const wave: number[] = [];
/** Сколько звука записано, мс (паузы не в счёт) — по событиям дорожек. */
let positionMs = 0;
/** Когда дорожка в последний раз что-то слышала. */
const heardAt: Record<Track, number> = { me: 0, others: 0 };
/** Последние уровни дорожек — общая точка «волны» берёт громкую из них. */
const lastLevel: Record<Track, { dbfs: number; at: number }> = { me: { dbfs: -90, at: 0 }, others: { dbfs: -90, at: 0 } };
/** Отметки текущей записи. */
let marks: Marker[] = [];

function trackOf(kind: string): Track {
  return kind === "me" ? "me" : "others";
}

function onLevel(track: Track, dbfs: number, ms: number): void {
  const now = performance.now();
  lastLevel[track] = { dbfs, at: now };
  if (dbfs > -45) heardAt[track] = now;
  positionMs = Math.max(positionMs, ms);
  // Точка «волны» — раз в 0,2 с, громкая из дорожек; события дорожек
  // приходят парами, вторую пару в тот же тик не считаем.
  if (track === "me" || !lastLevel.me.at || now - lastLevel.me.at > 300) {
    const loud = Math.max(...Object.values(lastLevel).filter((l) => now - l.at < 500).map((l) => l.dbfs), -90);
    wave.push(Math.max(0, Math.min(1, (loud + 60) / 55)));
    if (wave.length > WAVE_POINTS) wave.splice(0, wave.length - WAVE_POINTS);
  }
}

/** Отметки записи с ядра — после начала, после «Отметить момент». */
export function setRecordingMarks(list: Marker[]): void {
  marks = list.slice();
}

/** Время записи «чч:мм:сс» по записанному звуку. */
export function clock(ms: number): string {
  const t = Math.floor(ms / 1000);
  const p = (n: number) => String(n).padStart(2, "0");
  return `${p(Math.floor(t / 3600))}:${p(Math.floor(t / 60) % 60)}:${p(t % 60)}`;
}

function sizeText(ms: number, tracks: number): string {
  const mb = (ms * 32 * tracks) / 1_048_576;
  return mb < 1 ? "меньше 1 МБ" : `${mb.toFixed(mb < 10 ? 1 : 0).replace(".", ",")} МБ`;
}

/** Панель записи — одна на главной и на экране записи: время, живая
 * «волна», что слышно, отметки и кнопки. Всё, что надо видеть во время
 * встречи, — без перехода куда-либо. */
export function recordingPanel(): string {
  const r = state.recording;
  if (!r) return "";
  const pos = Math.max(positionMs, r.position_ms ?? 0);
  const source = (t: Track, title: string) => {
    const off = t === "others" && !r.system;
    return `<span class="src${off ? " is-off" : ""}" data-src="${t}"><span class="dot"></span>${title}
      <b data-src-state>${off ? "не пишется" : "…"}</b></span>`;
  };
  return `
    <div class="rec" data-rec>
      <div class="rec-top">
        <span class="rec-badge${r.paused ? " is-paused" : ""}"><span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${
          r.paused ? "Пауза" : "Идёт запись"
        }</span>
        <span class="rec-timer mono-num" data-timer>${clock(pos)}</span>
      </div>
      <canvas class="rec-wave" data-wave height="72" aria-label="Звук последней минуты"></canvas>
      <div class="rec-sources">
        ${source("me", "Микрофон")}
        ${source("others", "Звук компьютера")}
        <span class="rec-size xs muted" data-size>${sizeText(pos, r.system ? 2 : 1)}</span>
      </div>
      ${r.system_error ? `<p class="rec-warn small">Голоса собеседников не пишутся.</p>${permissionHelp("others")}` : ""}
      ${[...silent].map((t) => `<p class="rec-warn small" data-silent="${t}">${esc(SILENT_TEXT[t])}</p>`).join("")}
      <div class="rec-actions">
        <button class="ui-btn" data-rec-stop>Остановить</button>
        <button class="ui-btn" data-rec-stop-transcribe>Остановить и расшифровать</button>
        <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button>
        <span class="spacer"></span>
        <button class="ui-btn ui-btn-sm rec-mark-btn" data-rec-mark title="Отметить важный момент — он будет виден в расшифровке и на проигрывателе">★ Отметить момент</button>
      </div>
      ${
        marks.length
          ? `<ol class="rec-marks">${marks
              .map(
                (m, i) => `<li><span class="mono-num xs">${clock(m.ms)}</span>
                  <input class="input" data-mark-text="${i}" value="${esc(m.text)}" placeholder="что было — необязательно" maxlength="200"></li>`,
              )
              .join("")}</ol>`
          : `<p class="xs muted">Случилось важное — «★ Отметить момент»: место будет видно в расшифровке и на проигрывателе.</p>`
      }
    </div>`;
}

/** Рисует «волну» последней минуты: новые точки справа. */
function drawWave(canvas: HTMLCanvasElement): void {
  const w = canvas.clientWidth;
  if (!w) return;
  const dpr = window.devicePixelRatio || 1;
  if (canvas.width !== Math.round(w * dpr)) canvas.width = Math.round(w * dpr);
  const h = canvas.height;
  const ctx = canvas.getContext("2d");
  if (!ctx) return;
  ctx.clearRect(0, 0, canvas.width, h);
  const css = getComputedStyle(canvas);
  const step = canvas.width / WAVE_POINTS;
  const bar = Math.max(1, step * 0.6);
  ctx.fillStyle = css.getPropertyValue("--color-border").trim() || "#ddd";
  ctx.fillRect(0, h / 2, canvas.width, 1);
  ctx.fillStyle = css.getPropertyValue("--color-accent").trim() || "#a6533c";
  const start = WAVE_POINTS - wave.length;
  wave.forEach((v, i) => {
    const bh = Math.max(1, v * (h - 4));
    ctx.fillRect((start + i) * step, (h - bh) / 2, bar, bh);
  });
}

/** Кнопки панели, живая «волна», время и отметки. `onStop` — после остановки. */
export function bindRecording(root: HTMLElement, scope: Scope, onStop: () => void): void {
  const stop = async (b: HTMLButtonElement, thenTranscribe: boolean) => {
    const id = state.recording?.meeting_id;
    root.querySelectorAll<HTMLButtonElement>("[data-rec-stop], [data-rec-stop-transcribe]").forEach((x) => (x.disabled = true));
    b.textContent = "Останавливаю…";
    try {
      await api.recorderStop();
      await refreshRecording();
      if (thenTranscribe && id) void jobs.transcribe(id);
      onStop();
    } catch (err) {
      root.querySelectorAll<HTMLButtonElement>("[data-rec-stop], [data-rec-stop-transcribe]").forEach((x) => (x.disabled = false));
      toast(err);
    }
  };
  root.querySelector<HTMLButtonElement>("[data-rec-stop]")?.addEventListener("click", (e) => stop(e.currentTarget as HTMLButtonElement, false));
  root
    .querySelector<HTMLButtonElement>("[data-rec-stop-transcribe]")
    ?.addEventListener("click", (e) => stop(e.currentTarget as HTMLButtonElement, true));
  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);
    }
  });
  bindPermissionHelp(root);
  root.querySelector("[data-rec-mark]")?.addEventListener("click", async () => {
    const id = state.recording?.meeting_id;
    if (!id) return;
    try {
      const m = await api.addMarker(id, null);
      marks = m.markers;
      toast(`Отмечено: ${clock(marks.length ? Math.max(...marks.map((k) => k.ms)) : 0)}`, "ok");
      changed();
      // Сразу можно подписать: фокус в поле новой отметки.
      requestAnimationFrame(() => {
        const last = [...document.querySelectorAll<HTMLInputElement>("[data-mark-text]")].pop();
        last?.focus();
      });
    } catch (err) {
      toast(err);
    }
  });
  root.querySelectorAll<HTMLInputElement>("[data-mark-text]").forEach((input) =>
    input.addEventListener("change", async () => {
      const id = state.recording?.meeting_id;
      if (!id) return;
      marks[Number(input.dataset.markText)] = { ...marks[Number(input.dataset.markText)], text: input.value };
      try {
        marks = (await api.setMarkers(id, marks)).markers;
      } catch (err) {
        toast(err);
      }
    }),
  );

  // Живое: «волна», время, размер, что слышно — 5 раз в секунду.
  const tick = () => {
    const r = state.recording;
    if (!r) return;
    const canvas = root.querySelector<HTMLCanvasElement>("[data-wave]");
    if (canvas) drawWave(canvas);
    const pos = Math.max(positionMs, r.position_ms ?? 0);
    const t = root.querySelector("[data-timer]");
    if (t) t.textContent = clock(pos);
    const size = root.querySelector("[data-size]");
    if (size) size.textContent = sizeText(pos, r.system ? 2 : 1);
    const now = performance.now();
    for (const tr of ["me", "others"] as Track[]) {
      const el = root.querySelector<HTMLElement>(`[data-src="${tr}"]`);
      if (!el || el.classList.contains("is-off")) continue;
      const ok = !r.paused && now - heardAt[tr] < 3_000;
      el.classList.toggle("is-ok", ok);
      el.querySelector("[data-src-state]")!.textContent = r.paused ? "пауза" : ok ? "слышно" : "тишина";
    }
  };
  tick();
  scope.every(200, tick);
  scope.on(
    events.recorder(({ meeting_id, event }) => {
      if (!meeting_id || event.kind !== "track_failed") return;
      toast(`Дорожка «${event.data.speaker.kind === "me" ? "микрофон" : "звук компьютера"}» перестала писаться: ${event.data.error}`);
    }),
  );
}