skillok meeting
ade9583c661cc1280b544b11b9e57c1c48bba461
Перейти к файлу
Код
meeting srcplayer.ts
anc Экран записи и проигрыватель: волна, источники, отметки, звук ↔ текст Запись: - Время — по записанному звуку, паузы не в счёт (позиция сессии записи, ms в событиях уровня, position_ms в recorder_status). - Живая «волна» последней минуты, «Микрофон / Звук компьютера — слышно / тишина», объём записи; «Остановить и расшифровать». - «★ Отметить момент»: отметка с подписью (Meeting.markers, add_marker / set_markers); видна в расшифровке и на проигрывателе. Проигрыватель (player.ts): - Полоса внизу экрана записи: играть, ±15 с, скорость до 2×, «волна» всей встречи (клик и протяжка — перемотка, отметки, текущее место), «★» — отметить место. Две дорожки записи играют вместе и подравниваются. Живёт дольше перерисовок экрана — звук не прерывается. - Звук читается с диска протоколом asset: только из папки записей (protocol-asset, scope на папку, media-src в CSP); «волна» считается один раз и кэшируется в peaks.json. - Время реплики и отметки — кнопка «слушать отсюда», звучащая реплика подсвечивается; пробел — играть/пауза, ←/→ — 5 с. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> b82e259 2 октября 2026 г.
201 строка 8.4 КБ
player.ts
// Проигрыватель записи — полоса внизу экрана записи, как у MacWhisper:
// играть и пауза, ±15 секунд, скорость, «волна» всей встречи (клик или
// протяжка — перемотка, на ней отметки и текущее место) и «★ отметить здесь».
//
// У записи две дорожки — микрофон и звук компьютера — отдельными файлами;
// проигрыватель играет их вместе и подравнивает, если разошлись. Файлы
// читаются с диска через протокол `asset:` — копий звука не делается.

import { api, files, type Marker, type Meeting } from "./api";
import { clock } from "./recording";
import { toast } from "./state";

export interface Player {
  /** Корень полосы — экран вставляет его к себе после каждой перерисовки. */
  el: HTMLElement;
  /** Перемотать на `ms`; `play` — сразу играть. */
  seek(ms: number, play?: boolean): void;
  /** Где сейчас звук, мс. */
  current(): number;
  /** Отметки поменялись — перерисовать их на «волне». */
  setMarkers(markers: Marker[]): void;
  /** Звук сдвинулся — подписка; возвращает отписку. */
  onTime(cb: (ms: number) => void): () => void;
  destroy(): void;
}

const RATES = [1, 1.25, 1.5, 2];
/** Дорожки разошлись больше чем на это — подравнять. */
const DRIFT_S = 0.12;

export async function createPlayer(id: string, markers: Marker[], onMarked: (m: Meeting) => void): Promise<Player> {
  const view = await api.playback(id);
  const audios = await Promise.all(
    view.tracks.map(async (t) => {
      const a = new Audio(await files.src(t.path));
      a.preload = "auto";
      return a;
    }),
  );
  const master = audios[0];
  let marks = markers.slice();
  const listeners = new Set<(ms: number) => void>();
  const durationMs = () => (Number.isFinite(master.duration) && master.duration > 0 ? master.duration * 1000 : view.duration_ms);

  const el = document.createElement("div");
  el.className = "player";
  el.innerHTML = `
    <button class="player-btn player-play" data-pl-play aria-label="Играть">▶</button>
    <button class="player-btn" data-pl-back title="Назад на 15 секунд">−15</button>
    <button class="player-btn" data-pl-fwd title="Вперёд на 15 секунд">+15</button>
    <span class="player-time mono-num" data-pl-time>${clock(0)} / ${clock(view.duration_ms)}</span>
    <canvas class="player-wave" data-pl-wave height="40" aria-label="Вся запись: нажмите или протяните, чтобы перемотать"></canvas>
    <select class="input player-rate" data-pl-rate aria-label="Скорость">${RATES.map((r) => `<option value="${r}">${String(r).replace(".", ",")}×</option>`).join("")}</select>
    <button class="player-btn player-mark" data-pl-mark title="Отметить это место">★</button>`;
  const canvas = el.querySelector<HTMLCanvasElement>("[data-pl-wave]")!;
  const playBtn = el.querySelector<HTMLButtonElement>("[data-pl-play]")!;

  const playing = () => !master.paused;
  const now = () => master.currentTime * 1000;

  const draw = () => {
    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;
    const css = getComputedStyle(el);
    const accent = css.getPropertyValue("--color-accent").trim() || "#a6533c";
    const muted = css.getPropertyValue("--color-border-light").trim() || "#bbb";
    const ink = css.getPropertyValue("--kv-ink").trim() || "#0a0a0a";
    ctx.clearRect(0, 0, canvas.width, h);
    const total = durationMs();
    const played = total ? now() / total : 0;
    const n = view.peaks.length;
    const step = canvas.width / n;
    const bar = Math.max(1, step * 0.7);
    for (let i = 0; i < n; i++) {
      // Корень — чтобы тихая речь тоже была видна.
      const v = Math.sqrt(view.peaks[i]);
      const bh = Math.max(1, v * (h - 6));
      ctx.fillStyle = i / n <= played ? accent : muted;
      ctx.fillRect(i * step, (h - bh) / 2, bar, bh);
    }
    ctx.fillStyle = ink;
    for (const m of marks) {
      const x = total ? (m.ms / total) * canvas.width : 0;
      ctx.fillRect(x, 0, Math.max(2, dpr * 2), 6 * dpr);
    }
    ctx.fillRect(played * canvas.width, 0, Math.max(1, dpr), h);
    el.querySelector("[data-pl-time]")!.textContent = `${clock(now())} / ${clock(total)}`;
    playBtn.textContent = playing() ? "❚❚" : "▶";
    playBtn.setAttribute("aria-label", playing() ? "Пауза" : "Играть");
  };

  const seek = (ms: number, play = false) => {
    const t = Math.max(0, Math.min(ms, durationMs())) / 1000;
    audios.forEach((a) => (a.currentTime = t));
    if (play && !playing()) void start();
    draw();
    listeners.forEach((cb) => cb(t * 1000));
  };

  const start = async () => {
    try {
      await Promise.all(audios.map((a) => a.play()));
    } catch (e) {
      audios.forEach((a) => a.pause());
      toast(`Звук не играет: ${e}`);
    }
    draw();
  };
  const pause = () => {
    audios.forEach((a) => a.pause());
    draw();
  };
  const toggle = () => (playing() ? pause() : void start());

  playBtn.addEventListener("click", toggle);
  el.querySelector("[data-pl-back]")!.addEventListener("click", () => seek(now() - 15_000));
  el.querySelector("[data-pl-fwd]")!.addEventListener("click", () => seek(now() + 15_000));
  el.querySelector<HTMLSelectElement>("[data-pl-rate]")!.addEventListener("change", (e) => {
    const rate = Number((e.target as HTMLSelectElement).value);
    audios.forEach((a) => (a.playbackRate = rate));
  });
  el.querySelector("[data-pl-mark]")!.addEventListener("click", async () => {
    try {
      const m = await api.addMarker(id, Math.round(now()));
      marks = m.markers;
      draw();
      onMarked(m);
      toast(`Отмечено: ${clock(now())}`, "ok");
    } catch (e) {
      toast(e);
    }
  });

  // «Волна»: нажал — туда, протянул — следом.
  const toMs = (e: PointerEvent) => {
    const r = canvas.getBoundingClientRect();
    return ((e.clientX - r.left) / r.width) * durationMs();
  };
  canvas.addEventListener("pointerdown", (e) => {
    canvas.setPointerCapture(e.pointerId);
    seek(toMs(e));
  });
  canvas.addEventListener("pointermove", (e) => {
    if (canvas.hasPointerCapture(e.pointerId)) seek(toMs(e));
  });

  // Время идёт: подравнять дорожки, перерисовать, сказать экрану.
  master.addEventListener("timeupdate", () => {
    for (const a of audios.slice(1)) {
      if (Math.abs(a.currentTime - master.currentTime) > DRIFT_S) a.currentTime = master.currentTime;
    }
    draw();
    listeners.forEach((cb) => cb(now()));
  });
  master.addEventListener("ended", pause);
  master.addEventListener("loadedmetadata", draw);

  // Пробел — играть и пауза, стрелки — ±5 с; не когда человек пишет текст.
  const onKey = (e: KeyboardEvent) => {
    const t = e.target as HTMLElement;
    if (t.closest("input, textarea, select, button, [contenteditable]") || !el.isConnected) return;
    if (e.key === " ") {
      e.preventDefault();
      toggle();
    } else if (e.key === "ArrowLeft") seek(now() - 5_000);
    else if (e.key === "ArrowRight") seek(now() + 5_000);
  };
  document.addEventListener("keydown", onKey);
  const onResize = () => draw();
  window.addEventListener("resize", onResize);
  requestAnimationFrame(draw);

  return {
    el,
    seek,
    current: now,
    setMarkers(list) {
      marks = list.slice();
      draw();
    },
    onTime(cb) {
      listeners.add(cb);
      return () => listeners.delete(cb);
    },
    destroy() {
      document.removeEventListener("keydown", onKey);
      window.removeEventListener("resize", onResize);
      audios.forEach((a) => {
        a.pause();
        a.removeAttribute("src");
        a.load();
      });
      listeners.clear();
    },
  };
}