Клик по времени не работал: в скрипте страницы черновика снова оказался настоящий перевод строки вместо экранированного, и весь скрипт не выполнялся. Тот же огрех, что был в странице разбора. Проверка парности кавычек теперь охватывает обе страницы. Плеер приклеен к верху на обеих страницах: спорное место слушают, не прокручивая наверх. Черновик подсвечивал слово при несогласии любой модели - жёлтым выходил почти весь текст. Теперь по умолчанию нужно несогласие большинства: подсветки втрое меньше (152 слова против 385), но в чистой части 95,4 % верных вместо 98 %. Строгий режим доступен ссылкой; порог применяется при показе, поэтому переключение не требует пересборки. Режим ensemble: вторая модель подставляется там, где основная промолчала. Задаётся настройкой и параметром запроса - одну запись можно прогнать в обоих режимах и сравнить на странице разбора. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
413 lines
22 KiB
Python
413 lines
22 KiB
Python
"""Страница для разбора результата в браузере.
|
||
|
||
Когда диалог размечен неверно, по одному JSON не понять, чья это ошибка:
|
||
распознавание склеило реплики или LLM неверно расставила роли. Страница
|
||
показывает обе стороны - что отдал сервис и что получилось после разметки,
|
||
- и позволяет вставить ответ модели, чтобы увидеть результат сразу.
|
||
"""
|
||
import html
|
||
import json
|
||
from urllib.parse import quote
|
||
|
||
__all__ = ["render_job", "render_index", "render_draft"]
|
||
|
||
GAP_NOTICE_SEC = 3.0
|
||
|
||
|
||
def timecode(seconds: float) -> str:
|
||
return f"{int(seconds) // 60:02d}:{int(seconds) % 60:02d}"
|
||
|
||
|
||
def _stats_row(job: dict) -> str:
|
||
result = job.get("result") or job
|
||
stats = result.get("stats") or {}
|
||
timing = result.get("timing") or {}
|
||
duration = result.get("duration_sec", 0)
|
||
speech = stats.get("speech_sec", 0)
|
||
items = [
|
||
("Длительность", timecode(duration)),
|
||
("Реплик", str(stats.get("turns_count", len(result.get("turns", []))))),
|
||
("Предложений", str(len(result.get("sentences", [])))),
|
||
("Речи распознано", f"{speech:.0f} с ({speech / duration * 100:.0f} %)"
|
||
if duration else "-"),
|
||
("Режим", "диаризация" if stats.get("diarized") else "детектор речи"),
|
||
("Обработка", f"{timing.get('diarization_sec', 0):.0f} + "
|
||
f"{timing.get('asr_sec', 0):.0f} с"),
|
||
]
|
||
return "".join(
|
||
f'<div class="stat"><span class="k">{html.escape(k)}</span>'
|
||
f'<span class="v">{html.escape(v)}</span></div>' for k, v in items)
|
||
|
||
|
||
_CSS = """ :root { --bg:#fbfaf7; --fg:#22201d; --dim:#8a8580; --line:#e6e2db;
|
||
--a:#2f6f4f; --b:#7a4b8a; --warn:#b06a2c; }
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) { --bg:#1a1917; --fg:#e8e4dd; --dim:#8a8580;
|
||
--line:#33302c; --a:#7fc0a0; --b:#c79ad6; --warn:#d99a5c; } }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; padding:24px; background:var(--bg); color:var(--fg);
|
||
font:15px/1.55 -apple-system,Segoe UI,Roboto,sans-serif; }
|
||
.wrap { max-width:900px; margin:0 auto; }
|
||
h1 { font-size:19px; margin:0 0 14px; font-weight:600; }
|
||
h2 { font-size:15px; margin:26px 0 8px; font-weight:600; }
|
||
.hint { color:var(--dim); font-size:13px; margin:6px 0 14px; }
|
||
.bad { color:var(--warn); }
|
||
.stats { display:flex; flex-wrap:wrap; gap:6px 22px; padding:12px 0;
|
||
border-top:1px solid var(--line); border-bottom:1px solid var(--line);
|
||
margin-bottom:18px; }
|
||
.stat .k { color:var(--dim); font-size:13px; margin-right:6px; }
|
||
.stat .v { font-variant-numeric:tabular-nums; }"""
|
||
|
||
STATUS_RU = {"done": "готово", "failed": "ошибка",
|
||
"running": "в работе", "queued": "в очереди"}
|
||
|
||
|
||
def render_index(jobs: list[dict], token: str) -> str:
|
||
"""Список задач со ссылками на разбор.
|
||
|
||
Токен подставляется в ссылки: иначе по каждой пришлось бы дописывать его
|
||
руками, а ради этого список и заводится.
|
||
"""
|
||
import time as _time
|
||
|
||
suffix = f"?token={quote(token)}" if token else ""
|
||
rows = []
|
||
for job in jobs:
|
||
status = job.get("status", "")
|
||
when = _time.strftime("%d.%m %H:%M", _time.localtime(job.get("created_at", 0)))
|
||
title = html.escape(str(job.get("filename") or job["id"][:8]))
|
||
cell = (f'<a href="/v1/jobs/{job["id"]}/view{suffix}">{title}</a>'
|
||
if status == "done" else title)
|
||
note = html.escape(str(job.get("error") or "")[:80])
|
||
rows.append(
|
||
f'<tr class="st-{html.escape(status)}">'
|
||
f'<td class="dim">{when}</td>'
|
||
f'<td>{cell}</td>'
|
||
f'<td class="dim">{timecode(job.get("duration_sec") or 0)}</td>'
|
||
f'<td class="status">{STATUS_RU.get(status, status)}</td>'
|
||
f'<td class="dim err">{note}</td></tr>')
|
||
|
||
body = ("".join(rows) or
|
||
'<tr><td colspan="5" class="dim">задач пока нет</td></tr>')
|
||
return f"""<!doctype html>
|
||
<html lang="ru"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Задачи talkscore-asr</title>
|
||
<style>{_CSS}
|
||
table {{ width:100%; border-collapse:collapse; }}
|
||
td {{ padding:6px 10px 6px 0; border-bottom:1px solid var(--line);
|
||
vertical-align:top; }}
|
||
td.dim {{ color:var(--dim); font-size:13px; font-variant-numeric:tabular-nums;
|
||
white-space:nowrap; }}
|
||
td.err {{ white-space:normal; }}
|
||
.status {{ font-size:13px; }}
|
||
.st-failed .status {{ color:var(--warn); }}
|
||
.st-done .status {{ color:var(--a); }}
|
||
a {{ color:inherit; }}
|
||
</style></head><body><div class="wrap">
|
||
<h1>Задачи</h1>
|
||
<div class="hint">Готовые открываются по клику. Показаны последние {len(jobs)}.</div>
|
||
<table>{body}</table>
|
||
</div></body></html>"""
|
||
|
||
|
||
def render_draft(draft: dict, job_id: str, token: str = "",
|
||
strict: bool = False) -> str:
|
||
"""Черновик для правки на слух: выделено то, что стоит проверить.
|
||
|
||
strict помечает слово при несогласии любой модели: подсветки втрое
|
||
больше, но пропущенных ошибок меньше - 2 % против 4,6 %.
|
||
"""
|
||
suffix = f"?token={quote(token)}" if token else ""
|
||
need = 1 if strict else max(1, draft.get("compared", 1))
|
||
gaps = {round(g["start"], 1): g for g in draft.get("gaps", [])}
|
||
rows = []
|
||
for turn in draft["turns"]:
|
||
gap = gaps.pop(round(turn["start"], 1), None) or next(
|
||
(g for k, g in list(gaps.items()) if k < turn["start"]), None)
|
||
if gap is not None:
|
||
gaps.pop(round(gap["start"], 1), None)
|
||
rows.append(
|
||
f'<div class="gap">[{timecode(gap["start"])} - {timecode(gap["end"])}] '
|
||
f'ничего не распознано, {gap["end"] - gap["start"]:.0f} с - '
|
||
f'послушайте, не потерялась ли реплика</div>')
|
||
parts = [f'<span class="tc" data-at="{turn["start"]:.1f}">'
|
||
f'{timecode(turn["start"])}</span> ']
|
||
for word in turn["words"]:
|
||
votes = word.get("votes") or {}
|
||
variants = sorted(votes) if sum(votes.values()) >= need else []
|
||
if not variants:
|
||
parts.append(html.escape(word["text"]) + " ")
|
||
else:
|
||
options = " | ".join(html.escape(v) for v in variants)
|
||
parts.append(f'<mark>{html.escape(word["text"])}</mark>'
|
||
f'<span class="alt">[{options}]</span> ')
|
||
rows.append(f'<div class="turn draft">{"".join(parts)}</div>')
|
||
|
||
other = "" if strict else "&strict=1"
|
||
label = ("показывать только при несогласии большинства"
|
||
if strict else "показывать все расхождения")
|
||
mode_switch = (f'<a href="/v1/jobs/{html.escape(job_id)}/draft'
|
||
f'{suffix or "?"}{other if suffix else other.lstrip("&")}">{label}</a>. ')
|
||
|
||
total = draft.get("total_words", 0)
|
||
agreed = sum(1 for t in draft["turns"] for w in t["words"]
|
||
if sum((w.get("votes") or {}).values()) < need)
|
||
share = agreed / total * 100 if total else 0
|
||
return f"""<!doctype html>
|
||
<html lang="ru"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Черновик {html.escape(job_id[:8])}</title>
|
||
<style>{_CSS}
|
||
.turn.draft {{ display:block; padding:6px 0; border-bottom:1px solid var(--line); }}
|
||
.tc[data-at] {{ cursor:pointer; text-decoration:underline dotted; margin-right:6px; }}
|
||
mark {{ background:#ffe89a; color:#22201d; padding:0 2px; border-radius:2px; }}
|
||
.alt {{ color:var(--dim); font-size:13px; margin-left:3px; }}
|
||
.gap {{ color:var(--warn); font-size:13px; padding:6px 0; font-weight:600; }}
|
||
.sticky {{ position:sticky; top:0; z-index:5; background:var(--bg);
|
||
padding:8px 0 6px; border-bottom:1px solid var(--line); }}
|
||
audio {{ width:100%; margin:0; }}
|
||
button.copy {{ font:13px inherit; color:var(--fg); background:transparent;
|
||
border:1px solid var(--line); border-radius:4px; padding:3px 10px;
|
||
margin-left:10px; cursor:pointer; }}
|
||
</style></head><body><div class="wrap">
|
||
<h1>Черновик {html.escape(job_id[:8])}
|
||
<button class="copy" data-src="plain">скопировать для Word</button></h1>
|
||
<div class="stats">
|
||
<div class="stat"><span class="k">Моделей</span><span class="v">{draft.get("models", 1)}</span></div>
|
||
<div class="stat"><span class="k">Слов</span><span class="v">{total}</span></div>
|
||
<div class="stat"><span class="k">Согласны</span><span class="v">{agreed} ({share:.0f} %)</span></div>
|
||
<div class="stat"><span class="k">Проверить</span><span class="v">{total - agreed}</span></div>
|
||
<div class="stat"><span class="k">Пропусков</span><span class="v">{len(draft.get("gaps", []))}</span></div>
|
||
</div>
|
||
<div class="hint">{mode_switch}Жёлтым - расхождения между моделями, в скобках варианты
|
||
(«—» значит, что модель промолчала, «+слово» - услышала лишнее). Невыделенный
|
||
текст верен в 98 % случаев, его можно не слушать. Оранжевым - промежутки,
|
||
где не распознала ни одна модель. Щелчок по времени перематывает запись.</div>
|
||
<div class="sticky"><audio id="player" controls preload="none"
|
||
src="/v1/jobs/{html.escape(job_id)}/audio{suffix}"></audio></div>
|
||
{"".join(rows)}
|
||
</div>
|
||
<script>
|
||
const player = document.getElementById('player');
|
||
if (player) {{
|
||
document.querySelectorAll('.tc[data-at]').forEach(el =>
|
||
el.addEventListener('click', () => {{
|
||
player.currentTime = parseFloat(el.dataset.at); player.play();
|
||
}}));
|
||
}}
|
||
document.querySelectorAll('button.copy').forEach(b => b.addEventListener('click', async () => {{
|
||
const rows = [...document.querySelectorAll('.turn.draft')].map(el => {{
|
||
const time = el.querySelector('.tc').textContent.trim();
|
||
const text = [...el.childNodes].filter(n => !n.classList ||
|
||
(!n.classList.contains('tc'))).map(n => n.textContent).join('').trim();
|
||
return [time, text];
|
||
}});
|
||
const plain = rows.map(([t, x]) => t + ' ' + x).join('\\n\\n');
|
||
const rich = rows.map(([t, x]) => '<p><b>' + t + '</b> ' + x + '</p>').join('');
|
||
try {{
|
||
await navigator.clipboard.write([new ClipboardItem({{
|
||
'text/html': new Blob([rich], {{type: 'text/html'}}),
|
||
'text/plain': new Blob([plain], {{type: 'text/plain'}})}})]);
|
||
}} catch (e) {{ await navigator.clipboard.writeText(plain); }}
|
||
b.textContent = 'скопировано';
|
||
}}));
|
||
</script></body></html>"""
|
||
|
||
|
||
def render_job(job: dict, token: str = "") -> str:
|
||
"""Собирает самодостаточную страницу: без внешних файлов и запросов."""
|
||
result = job.get("result") or job
|
||
turns = result.get("turns") or []
|
||
sentences = result.get("sentences") or []
|
||
|
||
rows = []
|
||
previous_end = 0.0
|
||
for turn in turns:
|
||
gap = turn["start"] - previous_end
|
||
if gap >= GAP_NOTICE_SEC:
|
||
rows.append(f'<div class="gap">пропуск {gap:.0f} с '
|
||
f'[{timecode(previous_end)} - {timecode(turn["start"])}]</div>')
|
||
previous_end = turn["end"]
|
||
loudness = (turn.get("acoustics") or {}).get("loudness_db")
|
||
volume = f"{loudness:.0f} дБ" if loudness is not None else ""
|
||
mark = '<span class="tag">второй проход</span>' if turn.get("recovered") else ""
|
||
speaker = turn.get("speaker", 0)
|
||
who = f"Г{speaker}" if speaker else "Г0"
|
||
rows.append(
|
||
f'<div class="turn sp{speaker % 3}">'
|
||
f'<span class="tc" data-at="{turn["start"]:.1f}">'
|
||
f'{timecode(turn["start"])}</span>'
|
||
f'<span class="who">{who}</span>'
|
||
f'<span class="db">{volume}</span>'
|
||
f'<span class="text">{html.escape(turn["text"])}{mark}</span>'
|
||
f'</div>')
|
||
|
||
# Внутри <script> символ < опасен: последовательность </script> в тексте
|
||
# закрыла бы тег и всё, что дальше, браузер прочитал бы как разметку.
|
||
if sentences:
|
||
roles_block = (
|
||
'<div class="hint">Вставьте ответ модели - строку из букв M и C, '
|
||
'по одной на предложение. Диалог соберётся ниже, и станет видно, '
|
||
'где ошибка: в тексте от сервиса или в расстановке ролей.</div>'
|
||
'<textarea id="roles" placeholder="MMCMMCC..." spellcheck="false"></textarea>')
|
||
else:
|
||
# Массив предложений появился в 0.14.0. Без него собирать диалог не из
|
||
# чего, и молчаливо пустой блок выглядел бы поломкой страницы.
|
||
roles_block = (
|
||
'<div class="hint bad">В этом результате нет разбивки на предложения: '
|
||
'запись обработана версией сервиса старше 0.14.0. Отправьте её заново, '
|
||
'чтобы собрать диалог по ролям.</div>')
|
||
|
||
# Плеер появляется, только если запись сохранена: иначе он показывал бы
|
||
# пустую полосу и выглядел бы поломкой.
|
||
if job.get("has_record"):
|
||
suffix = f"?token={quote(token)}" if token else ""
|
||
player = (f'<div class="sticky"><audio id="player" controls preload="none" '
|
||
f'src="/v1/jobs/{html.escape(str(job.get("job_id", "")))}/audio{suffix}">'
|
||
'</audio></div>'
|
||
'<div class="hint">Щелчок по времени слева от реплики перематывает запись.</div>')
|
||
else:
|
||
player = ('<div class="hint">Запись не сохранена: включите keep_records '
|
||
'в config.toml, чтобы слушать разговор прямо здесь.</div>')
|
||
|
||
job_id_esc = html.escape(str(job.get("job_id", "")))
|
||
token_suffix = f"?token={quote(token)}" if token else ""
|
||
|
||
payload = json.dumps(
|
||
[{"n": s["n"], "text": s["text"], "start": s["start"]} for s in sentences],
|
||
ensure_ascii=False).replace("<", "\\u003c").replace("\u2028", "\\u2028")
|
||
|
||
return f"""<!doctype html>
|
||
<html lang="ru"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Расшифровка {html.escape(str(job.get("job_id", "")))[:8]}</title>
|
||
<style>
|
||
{_CSS}
|
||
.turn {{ display:grid; grid-template-columns:52px 34px 58px 1fr; gap:8px;
|
||
padding:5px 0; border-bottom:1px solid var(--line); }}
|
||
.tc, .db {{ color:var(--dim); font-size:12px; font-variant-numeric:tabular-nums; }}
|
||
.tc[data-at] {{ cursor:pointer; text-decoration:underline dotted; }}
|
||
.tc[data-at]:hover {{ color:var(--fg); }}
|
||
.sticky {{ position:sticky; top:0; z-index:5; background:var(--bg);
|
||
padding:8px 0 6px; border-bottom:1px solid var(--line); }}
|
||
audio {{ width:100%; margin:0; }}
|
||
.who {{ font-weight:600; font-size:13px; }}
|
||
.sp1 .who {{ color:var(--a); }} .sp2 .who {{ color:var(--b); }}
|
||
.gap {{ color:var(--warn); font-size:12px; padding:5px 0 5px 94px; }}
|
||
.tag {{ color:var(--warn); font-size:11px; margin-left:8px;
|
||
border:1px solid var(--warn); border-radius:3px; padding:0 4px; }}
|
||
textarea {{ width:100%; min-height:70px; font:12px/1.4 ui-monospace,monospace;
|
||
background:transparent; color:var(--fg); border:1px solid var(--line);
|
||
border-radius:4px; padding:8px; }}
|
||
.roles .m {{ color:var(--a); }} .roles .c {{ color:var(--b); }}
|
||
.roles .line {{ display:grid; grid-template-columns:76px 1fr; gap:10px;
|
||
padding:4px 0; border-bottom:1px solid var(--line); }}
|
||
.who2 {{ font-weight:600; font-size:13px; }}
|
||
a.button {{ display:inline-block; font-size:13px; color:var(--fg);
|
||
border:1px solid var(--line); border-radius:4px; padding:5px 12px;
|
||
text-decoration:none; }}
|
||
a.button:hover {{ border-color:var(--dim); }}
|
||
button.copy {{ font:13px inherit; color:var(--fg); background:transparent;
|
||
border:1px solid var(--line); border-radius:4px; padding:3px 10px;
|
||
margin-left:10px; cursor:pointer; font-weight:400; }}
|
||
button.copy:hover {{ border-color:var(--dim); }}
|
||
</style></head><body><div class="wrap">
|
||
<h1>Расшифровка {html.escape(str(job.get("job_id", ""))[:8])} - {html.escape(str(job.get("filename", "")))}</h1>
|
||
<div class="stats">{_stats_row(job)}</div>
|
||
|
||
{player}
|
||
<h2>Черновик для правки на слух</h2>
|
||
<div class="hint">Несколько моделей распознают запись, подсвечиваются только
|
||
расхождения между ними. Там, где все согласны, текст верен в 98 % случаев -
|
||
слушать нужно четверть текста вместо всего. Сборка занимает несколько минут.</div>
|
||
<a class="button" href="/v1/jobs/{job_id_esc}/draft{token_suffix}">собрать черновик</a>
|
||
|
||
<h2>Что отдал сервис <button class="copy" data-src="plain">скопировать для Word</button></h2>
|
||
<div class="hint">Реплики так, как их нарезал сервис. Г0 значит, что разделение
|
||
по голосам выключено и говорящий неизвестен - это норма.</div>
|
||
{"".join(rows) or "<div class=hint>реплик нет</div>"}
|
||
|
||
<h2>Разметка ролей</h2>
|
||
{roles_block}
|
||
<div id="check" class="hint"></div>
|
||
<div id="out" class="roles"></div>
|
||
<button class="copy" id="copyroles" data-src="roles" hidden>скопировать диалог для Word</button>
|
||
</div>
|
||
<script>
|
||
const S = {payload};
|
||
const box = document.getElementById('roles');
|
||
const out = document.getElementById('out');
|
||
const check = document.getElementById('check');
|
||
function tc(s) {{ const m = Math.floor(s/60), r = Math.floor(s%60);
|
||
return String(m).padStart(2,'0')+':'+String(r).padStart(2,'0'); }}
|
||
function draw() {{
|
||
const letters = (box.value.toUpperCase().match(/[MC]/g) || []);
|
||
if (!letters.length) {{ out.innerHTML=''; check.textContent=''; return; }}
|
||
if (letters.length !== S.length) {{
|
||
check.innerHTML = '<span class="bad">букв ' + letters.length +
|
||
', а предложений ' + S.length + ' - разметка сдвинута, показываю как есть</span>';
|
||
}} else {{ check.textContent = 'букв ' + letters.length + ', совпадает с числом предложений'; }}
|
||
let html = '', prev = null, buf = [], start = 0;
|
||
const flush = () => {{ if (!buf.length) return;
|
||
const cls = prev === 'M' ? 'm' : 'c';
|
||
const who = prev === 'M' ? 'МЕНЕДЖЕР' : 'КЛИЕНТ';
|
||
html += '<div class="line"><span class="who2 '+cls+'">'+tc(start)+' '+who+
|
||
'</span><span>'+buf.join(' ')+'</span></div>'; buf = []; }};
|
||
S.forEach((s, i) => {{ const r = letters[i] || 'M';
|
||
if (r !== prev) {{ flush(); prev = r; start = s.start; }}
|
||
buf.push(s.text.replace(/[&<>]/g, c => ({{'&':'&','<':'<','>':'>'}})[c])); }});
|
||
flush(); out.innerHTML = html;
|
||
const rolesButton = document.getElementById('copyroles');
|
||
if (rolesButton) rolesButton.hidden = !html;
|
||
}}
|
||
if (box) box.addEventListener('input', draw);
|
||
|
||
// Word вставляет из буфера разметку, если она есть: имена говорящих
|
||
// останутся полужирными, а абзацы - абзацами. Простой текст кладём рядом,
|
||
// для редакторов, которые разметку не берут.
|
||
function collect(src) {{
|
||
if (src === 'roles') {{
|
||
const lines = [...out.querySelectorAll('.line')].map(el => [
|
||
el.querySelector('.who2').textContent.trim(),
|
||
el.lastElementChild.textContent.trim()]);
|
||
return lines;
|
||
}}
|
||
return [...document.querySelectorAll('.turn')].map(el => [
|
||
el.querySelector('.tc').textContent.trim() + ' ' +
|
||
el.querySelector('.who').textContent.trim(),
|
||
el.querySelector('.text').textContent.trim()]);
|
||
}}
|
||
function esc(t) {{ return t.replace(/[&<>]/g, c => ({{'&':'&','<':'<','>':'>'}}[c])); }}
|
||
async function copyAs(src, button) {{
|
||
const rows = collect(src);
|
||
if (!rows.length) return;
|
||
const plain = rows.map(([who, text]) => who + ': ' + text).join('\\n\\n');
|
||
const rich = '<div>' + rows.map(([who, text]) =>
|
||
'<p><b>' + esc(who) + ':</b> ' + esc(text) + '</p>').join('') + '</div>';
|
||
try {{
|
||
await navigator.clipboard.write([new ClipboardItem({{
|
||
'text/html': new Blob([rich], {{type: 'text/html'}}),
|
||
'text/plain': new Blob([plain], {{type: 'text/plain'}})}})]);
|
||
}} catch (e) {{
|
||
// Старые браузеры и небезопасное соединение: кладём хотя бы текст.
|
||
await navigator.clipboard.writeText(plain);
|
||
}}
|
||
const was = button.textContent;
|
||
button.textContent = 'скопировано';
|
||
setTimeout(() => {{ button.textContent = was; }}, 1500);
|
||
}}
|
||
document.querySelectorAll('button.copy').forEach(b =>
|
||
b.addEventListener('click', () => copyAs(b.dataset.src, b)));
|
||
|
||
// Перемотка по щелчку на времени: слушать нужное место, не таская ползунок.
|
||
const player = document.getElementById('player');
|
||
if (player) {{
|
||
document.querySelectorAll('.tc[data-at]').forEach(el =>
|
||
el.addEventListener('click', () => {{
|
||
player.currentTime = parseFloat(el.dataset.at);
|
||
player.play();
|
||
}}));
|
||
}}
|
||
</script></body></html>"""
|