anc
macOS: запись звука Zoom и микрофона
- Звук Zoom — ScreenCaptureKit с фильтром по приложению us.zoom.xos,
сразу 16 кГц моно; микрофон — CoreAudio через cpal.
- Info.plist с объяснениями для запросов доступа (микрофон, системный
звук), minimumSystemVersion 13.0, сборка .app.
- Swift-мост ScreenCaptureKit при одних Command Line Tools: путь к
библиотекам совместимости Swift и rpath /usr/lib/swift в build.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1b62ba2
30 сентября 2026 г.
home.ts
// Главная: записать встречу (или добавить файл) и список записей.
import { api, files, formatDate, formatDuration, meetingDuration, type Meeting, type SettingsView } from "../api";
import { esc } from "../dom";
import { sections } from "../markdown";
import { bindRecording, recordingPanel } from "../recording";
import type { Scope } from "../scope";
import { addAudio, onChange, startRecording, state } from "../state";
function hero(view: SettingsView): string {
if (state.recording) {
return `<section class="hero is-live">${recordingPanel(view.settings.warning_text)}</section>`;
}
const pick = `<button class="link-btn" data-pick>выберите файл</button>`;
if (!view.capture_supported) {
return `
<section class="hero">
<h1>Добавьте запись встречи</h1>
<p class="hero-text">На этой системе запись не поддерживается. Добавьте готовый файл: mp3, m4a, wav и другие.</p>
<button class="ui-btn" data-pick>Выбрать аудиофайл</button>
<p class="small muted">Или перетащите файл в окно.</p>
</section>`;
}
return `
<section class="hero">
<h1>Запишите встречу</h1>
<p class="hero-text">Запустите встречу в Zoom и нажмите «Записать» — запишутся собеседники и вы. Потом расшифруете и получите конспект и задачи.</p>
<button class="ui-btn hero-btn" data-record>Записать</button>
<p class="small muted">Уже есть запись? Перетащите аудиофайл в окно или ${pick}.</p>
</section>`;
}
function status(m: Meeting, note: string | undefined): string {
if (m.recording) return `<span class="status status-live">Идёт запись</span>`;
if (!m.audio.length) return `<span class="status">Нет звука</span>`;
if (!m.transcribed_at) return `<span class="status status-todo">Нужно расшифровать</span>`;
const got = sections(note ?? "")
.map((s) => s.heading)
.filter((h) => h && h !== "Расшифровка");
return `<span class="status status-done">${got.length ? esc(got.join(" · ")) : "Расшифровано"}</span>`;
}
function list(meetings: Meeting[], notes: Map<string, string>): string {
if (!meetings.length) return "";
return `
<section class="records">
<h2>Записи</h2>
<div class="record-list">
${meetings
.map((m) => {
const meta = [formatDate(m.created_at), formatDuration(meetingDuration(m))].filter(Boolean).join(" · ");
return `
<a class="record" href="#/m/${encodeURIComponent(m.id)}">
<span class="record-title">${esc(m.title)}</span>
<span class="record-meta small muted">${esc(meta)}</span>
${status(m, notes.get(m.id))}
</a>`;
})
.join("")}
</div>
</section>`;
}
export async function renderHome(root: HTMLElement, scope: Scope): Promise<void> {
const [view, meetings] = await Promise.all([api.getSettings(), api.listMeetings()]);
// Что уже получено по записи — из её заметки; записей немного, читаем все.
const details = await Promise.all(meetings.filter((m) => m.transcribed_at).map((m) => api.getMeeting(m.id)));
if (!scope.alive) return;
const notes = new Map(details.map((d) => [d.meeting.id, d.note]));
const draw = () => {
root.innerHTML = `
<div class="home">
${hero(view)}
${
view.openrouter_key
? ""
: `<p class="notice">Чтобы расшифровывать записи, подключите ИИ — <a href="#/settings">это минута</a>.</p>`
}
${list(meetings, notes)}
</div>`;
root.querySelector("[data-record]")?.addEventListener("click", startRecording);
root.querySelectorAll("[data-pick]").forEach((b) => b.addEventListener("click", async () => addAudio(await files.pickAudio())));
// После «Остановить» переход к записи делает подписка ниже.
bindRecording(root, scope, view.settings.warning_text, () => undefined);
};
draw();
// Запись остановили или поставили на паузу — карточка меняется.
let last = JSON.stringify(state.recording);
const off = onChange(async () => {
const now = JSON.stringify(state.recording);
if (now === last || !scope.alive) return;
const stoppedId = last && !state.recording ? (JSON.parse(last) as { meeting_id: string }).meeting_id : null;
last = now;
if (stoppedId) {
location.hash = `#/m/${encodeURIComponent(stoppedId)}`;
return;
}
draw();
});
scope.on(Promise.resolve(off));
}