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 г.
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 {
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 = `
▶
−15
+15
${clock(0)} / ${clock(view.duration_ms)}
${RATES.map((r) => `${String(r).replace(".", ",")}×`).join("")}
★`;
const canvas = el.querySelector("[data-pl-wave]")!;
const playBtn = el.querySelector("[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("[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();
},
};
}