montevoice/web/join.html
Distill c7f91eb4b8 Montevoice: первая публикация кода
Сервис аудиосозвонов с автоматическим протоколом, работает с 10 августа 2026 года.
Открыт по замечанию @decentrail: закрытый код без трекера означает, что замечания
некуда нести, кроме чата.

Не входит: конфигурация с ключами, записи созвонов, модели синтеза речи.
2026-09-01 08:06:05 +00:00

340 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html><html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>__ROOM__ — Montevoice</title>
<style>
:root{--bg:#0d1117;--card:#161b22;--line:#232b36;--txt:#e6edf3;--dim:#8b949e;--accent:#3b82f6;--live:#ef4444;--speak:#22c55e;--hand:#f59e0b}
*{box-sizing:border-box}
body{background:var(--bg);color:var(--txt);font-family:system-ui,-apple-system,sans-serif;max-width:1080px;margin:0 auto;padding:28px 16px 40px;-webkit-font-smoothing:antialiased}
#gate{max-width:520px}
.brand{display:flex;align-items:center;gap:9px;font-weight:700;font-size:1.05em;letter-spacing:-.01em}
.brand .dot{width:9px;height:9px;border-radius:50%;background:var(--accent)}
.brand small{font-weight:400;color:var(--dim);font-size:.8em;letter-spacing:0}
h1{font-size:1.45em;font-weight:650;margin:18px 0 6px;letter-spacing:-.02em}
.note{color:var(--dim);font-size:.92em;line-height:1.5}
input,textarea,button{font-size:1em;padding:12px 14px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--txt);width:100%;margin-top:10px;font-family:inherit}
input:focus,textarea:focus{outline:none;border-color:var(--accent)}
button{border:none;cursor:pointer;font-weight:600;transition:opacity .15s}
button:hover{opacity:.88}
button:disabled{opacity:.45;cursor:default}
.off{background:#5b2028!important;border-color:#9f3542!important;color:#fecaca}
.primary{background:var(--accent)}
.sec{background:#21262d;border:1px solid var(--line)}
.row{display:flex;gap:8px;flex-wrap:wrap}
.row button{margin-top:10px;flex:1 1 150px;width:auto}
#gate button{width:100%}
.live{display:inline-flex;align-items:center;gap:8px;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.35);color:#fca5a5;padding:7px 12px;border-radius:999px;font-size:.85em;margin-top:14px}
.live .pulse{width:8px;height:8px;border-radius:50%;background:var(--live);animation:pulse 1.8s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.stage{display:grid;grid-template-columns:minmax(0,1fr);gap:16px;align-items:start;margin-top:16px}
@media(min-width:900px){.stage{grid-template-columns:minmax(0,1fr) 340px}.chat{margin-top:0}}
#parts{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
.p{display:flex;align-items:center;gap:10px;padding:10px 12px;min-height:62px;background:var(--card);border:1px solid var(--line);border-radius:12px;transition:border-color .2s}
.p.speak{border-color:var(--speak)}
.av{width:36px;height:36px;min-width:36px;border-radius:50%;display:grid;place-items:center;font-size:17px;background:#1f2937;position:relative}
.p.bot .av{background:#1e293b}
.p.speak .av::after{content:'';position:absolute;inset:-4px;border-radius:50%;border:2px solid var(--speak);animation:ring 1.2s ease-out infinite}
@keyframes ring{0%{transform:scale(.9);opacity:.9}100%{transform:scale(1.15);opacity:0}}
.who{flex:1;min-width:0}
.nm{font-weight:600;font-size:.95em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.rl{color:var(--dim);font-size:.76em;margin-top:1px}
.badge{background:rgba(245,158,11,.15);color:var(--hand);border:1px solid rgba(245,158,11,.4);padding:3px 9px;border-radius:999px;font-size:.82em;font-weight:600;white-space:nowrap}
.mut{color:var(--dim);font-size:1em}
.mutebtn{width:auto;margin:0;padding:4px 8px;background:#21262d;border:1px solid var(--line);border-radius:8px;font-size:.85em;color:var(--dim);opacity:.55}
.mutebtn:hover{opacity:1;color:#fca5a5}
.floorbtn{width:auto;margin:0;padding:4px 8px;background:#21262d;border:1px solid var(--line);border-radius:8px;font-size:.85em;color:var(--dim);opacity:.6}
.floorbtn:hover{opacity:1;color:#93c5fd}
.floorbtn.on{background:rgba(59,130,246,.2);border-color:var(--accent);color:#93c5fd;opacity:1}
.state{font-size:.74em;margin-top:2px;display:block}
.state.thinking,.state.hearing{color:#fcd34d}.state.speaking{color:var(--speak)}.state.listening{color:var(--dim)}
.state.done{color:#93c5fd}.state.lost{color:#f87171}.state.floor{color:#93c5fd}
.hint{background:rgba(59,130,246,.12);border:1px solid rgba(59,130,246,.4);color:#93c5fd;padding:10px 13px;border-radius:10px;font-size:.9em;margin-top:12px;line-height:1.45}
.hint.warn{background:rgba(245,158,11,.12);border-color:rgba(245,158,11,.45);color:#fcd34d}
button.off{background:#7f1d1d;border:1px solid #b91c1c}
#status{color:var(--dim);font-size:.84em;margin-top:16px;line-height:1.5}
#err{color:#f87171;font-size:.9em;margin-top:10px}
.transcript{margin-top:14px;padding:12px;background:var(--card);border:1px solid var(--line);border-radius:10px;display:flex;align-items:center;gap:8px}.transcript a{color:#60a5fa;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.transcript button{width:auto;margin:0;padding:7px 10px}.history{margin-top:22px}.history a{display:block;color:#60a5fa;margin:8px 0}
.chat{margin-top:16px;position:relative}.chat h2{font-size:1.05em;margin:0 0 8px}.chatlog{height:min(52vh,420px);overflow-y:auto;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px}.chatmsg{margin:8px 0;white-space:pre-wrap;overflow-wrap:anywhere}.chatmeta{color:var(--dim);font-size:.78em;margin-bottom:2px}.chatnote{color:var(--dim);font-size:.82em;text-align:center;padding:5px}.chat textarea{resize:vertical;min-height:48px;max-height:140px}.unread{position:absolute;right:8px;bottom:65px;width:auto;margin:0;padding:5px 9px;border-radius:999px;background:var(--accent);font-size:.78em}
</style></head><body>
<div class="brand"><span class="dot"></span>Montevoice<small>· Синаполис</small></div>
<div id="gate">
<h1>«__ROOM__»</h1>
<p class="note">Аудиосозвон по ссылке. Разговор записывается и превращается в текст автоматически — включать запись вручную не нужно.</p>
<input id="name" placeholder="Ваше имя" maxlength="40" autocomplete="name">
<button id="joinbtn" class="primary">Войти в созвон</button>
<div id="err"></div>
<div id="history" class="history"></div>
<p class="note"><a id="roomArchiveGate" target="_blank" rel="noopener" style="color:#60a5fa">Протоколы прошлых созвонов в этой комнате</a></p>
</div>
<div id="call" hidden>
<h1>«__ROOM__»</h1>
<div class="live"><span class="pulse"></span>Идёт запись</div>
<div id="micnote" class="hint">Микрофон выключен — нажмите «Микрофон», чтобы вас услышали</div>
<div id="ownernote" class="hint" hidden></div>
<div id="netnote" class="hint warn" hidden></div>
<div id="micdenied" class="hint warn" hidden>Браузер не дал доступ к микрофону. Проверьте по порядку: 1) микрофон включён в системе; 2) в адресной строке разрешите сайту доступ к микрофону; 3) микрофон не занят другой программой. После этого нажмите «Микрофон» ещё раз.</div>
<div class="stage">
<div>
<div id="parts"></div>
<div class="row"><button id="mute" class="sec">🎙 Микрофон включён</button><button id="hand" class="sec">✋ Поднять руку</button></div>
<div class="row"><button id="reaudio" class="sec">🔈 Пропал звук</button><button id="finish" class="sec" hidden>⏹ Завершить созвон</button><button id="leave" class="sec">Выйти</button></div>
<div id="transcript" class="transcript" hidden><a id="transcriptLink" target="_blank" rel="noopener"></a><button id="copyTranscript" class="sec">Скопировать</button></div>
<div class="transcript"><a id="roomArchive" target="_blank" rel="noopener">Все протоколы этой комнаты</a></div>
<div id="status">Транскрипт с именами и таймкодами соберётся сам, когда все выйдут. Поднятая рука видна всем и попадает в транскрипт — ведущий увидит, кто просит слова. «Завершить созвон» закрывает запись сразу для всех, даже если кто-то забыл выйти.</div>
</div>
<section class="chat"><h2>Чат</h2><div id="chatlog" class="chatlog"><div class="chatnote">Показаны только сообщения после вашего входа. Полная запись останется в транскрипте.</div></div><button id="unread" class="unread" hidden></button><textarea id="chatinput" maxlength="2000" placeholder="Сообщение — Enter отправить, Shift+Enter перенос"></textarea></section>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2/dist/livekit-client.umd.min.js"></script>
<script>
const $=id=>document.getElementById(id);const ROOM='__ROOM__';
const BASE=location.pathname.replace(/\/[^/]*$/,'')||'';
const SIGNAL_VERSION=1;let room,muted=false,handRaisedAt=null,unread=0,owner='',ownerName='';
const floors=new Set(),agentStates=new Map();const hands=new Map();
const HIDDEN=['recorder-bot'];
const TRANSCRIPT_KEY='montevoice:transcript:'+ROOM,HISTORY_KEY='montevoice:transcripts';
// Стабильная метка вкладки: при повторном входе сервер выдаёт ту же identity,
// LiveKit вышибает зависшую прошлую сессию — не остаётся двух плашек одного человека.
let CID=localStorage.getItem('montevoice:cid');
if(!CID){CID=Math.random().toString(36).slice(2,10)+Math.random().toString(36).slice(2,6);localStorage.setItem('montevoice:cid',CID);}
$('name').value=localStorage.getItem('callname')||'';
// Архив комнаты открывается пропуском — токеном протокола, который есть только
// у того, кто в этой комнате был. По одному имени комнаты список не показывается.
function archiveHref(){
const saved=localStorage.getItem(TRANSCRIPT_KEY)||'';
const token=(saved.match(/\/t\/([^/?#]+)/)||[])[1];
return token?(BASE+'/r/'+encodeURIComponent(ROOM)+'?t='+encodeURIComponent(token)):'';
}
function applyArchiveLinks(){
const href=archiveHref();
for(const id of ['roomArchive','roomArchiveGate']){
const el=$(id);if(!el)continue;
el.href=href||'#';
const box=id==='roomArchive'?el.parentElement:el.parentElement;
if(box)box.hidden=!href;
}
}
applyArchiveLinks();
showHistory();
$('joinbtn').onclick=join;
$('name').addEventListener('keydown',e=>{if(e.key==='Enter')join();});
function isBot(p){const id=(p.identity||'').toLowerCase();
return id.endsWith('-voice')||id.startsWith('distill-')||/^(echo|arkhivolt|nodus|isaac|rin|murr|faber|alter-victor)[-.]/.test(id);}
function isHidden(p){return HIDDEN.some(h=>(p.identity||'').toLowerCase().startsWith(h));}
async function join(){
const name=$('name').value.trim()||'гость';
localStorage.setItem('callname',name);
$('joinbtn').disabled=true;$('err').textContent='';
try{
const r=await fetch(BASE+'/api/token?room='+encodeURIComponent(ROOM)+'&name='+encodeURIComponent(name)+'&cid='+encodeURIComponent(CID));
const j=await r.json();
if(!j.token)throw new Error(j.error||'нет токена');
room=new LivekitClient.Room();
room.on('trackSubscribed',t=>{if(t.kind==='audio'){const el=t.attach();el.dataset.audioSink='1';document.body.appendChild(el);}});
room.on('reconnecting',()=>{$('netnote').textContent='Связь потеряна — переподключаюсь…';$('netnote').hidden=false;});
room.on('reconnected',()=>{$('netnote').hidden=true;reattachAudio();});
room.on('participantConnected',render);
room.on('participantDisconnected',p=>{hands.delete(p.identity);if(p.identity===owner){owner='';ownerName='';applyOwner();}render();});
room.on('activeSpeakersChanged',render);
room.on('trackMuted',render);room.on('trackUnmuted',render);
room.on('dataReceived',(payload,participant)=>onData(payload,participant));
room.on('disconnected',()=>{$('call').hidden=true;$('gate').hidden=false;$('joinbtn').disabled=false;showHistory();});
await room.connect(j.url,j.token);
await room.localParticipant.setMicrophoneEnabled(false);muted=true;
$('mute').textContent='🔇 Микрофон выключен';$('mute').classList.add('off');
await publishSignal({v:SIGNAL_VERSION,type:'hand-state-request'});
$('gate').hidden=true;$('call').hidden=false;render();
}catch(e){$('err').textContent='Не удалось подключиться: '+e.message;$('joinbtn').disabled=false;}
}
function micOff(p){try{const t=[...p.audioTrackPublications.values()][0];return t?t.isMuted:false;}catch{return false;}}
function render(){
if(!room)return;
const ps=[room.localParticipant,...room.remoteParticipants.values()].filter(p=>!isHidden(p));
const queue=[...hands.entries()].sort((a,b)=>a[1]-b[1]||a[0].localeCompare(b[0]));
const pos=new Map(queue.map(([id],i)=>[id,i+1]));
const ordered=[...ps].sort((a,b)=>{const ap=pos.get(a.identity),bp=pos.get(b.identity);return ap&&bp?ap-bp:ap?-1:bp?1:ps.indexOf(a)-ps.indexOf(b);});
// Рука опускается сама, когда человек заговорил: её постоянно забывают снять.
if(handRaisedAt!==null&&!muted&&room.localParticipant.isSpeaking)lowerHand();
const html=ordered.map(p=>{
const n=pos.get(p.identity),bot=isBot(p),me=p===room.localParticipant;
return '<div data-id="'+esc(p.identity)+'" class="p'+(p.isSpeaking?' speak':'')+(bot?' bot':'')+'">'+
'<div class="av">'+(bot?'🤖':'👤')+'</div>'+
'<div class="who"><div class="nm">'+esc(p.name||p.identity)+(me?' <span style="color:var(--dim);font-weight:400">(вы)</span>':'')+'</div>'+
'<div class="rl">'+(bot?'агент города':'человек')+'</div>'+(bot?stateLine(p.identity):'')+'</div>'+
(micOff(p)?'<span class="mut" title="микрофон выключен">🔇</span>':
(me||!isOwner())?'':'<button class="mutebtn" data-mute="'+esc(p.identity)+'" title="выключить микрофон этому участнику">🔇</button>')+
((bot&&isOwner())?'<button class="floorbtn'+(floors.has(p.identity)?' on':'')+'" data-floor="'+esc(p.identity)+'" title="'+(floors.has(p.identity)?'забрать трибуну':'дать слово как основному докладчику: агента не будут перебивать')+'">🎤</button>':'')+
(n?'<span class="badge">✋ '+n+'</span>':'')+'</div>';}).join('');
// Без изменений DOM не трогаем вовсе: перерисовка раз в секунду давала
// дрожание списка в Firefox (сообщил kirill на созвоне 12.08).
if($('parts').dataset.sig===html)return;
const before=new Map([...$('parts').children].map(el=>[el.dataset.id,el.getBoundingClientRect().top]));
$('parts').innerHTML=html;$('parts').dataset.sig=html;
[...$('parts').children].forEach(el=>{const old=before.get(el.dataset.id),now=el.getBoundingClientRect().top;
if(old!==undefined&&Math.abs(old-now)>1){el.style.transform=`translateY(${old-now}px)`;el.style.transition='none';
requestAnimationFrame(()=>{el.style.transition='transform .28s ease,border-color .2s';el.style.transform='translateY(0)';});}});
}
$('parts').addEventListener('click',async e=>{
const floorBtn=e.target.closest('[data-floor]');
if(floorBtn&&room){
const id=floorBtn.dataset.floor,granted=!floors.has(id);
floorBtn.disabled=true;setTimeout(()=>{floorBtn.disabled=false;},600);
await publishSignal({v:SIGNAL_VERSION,type:'floor',target:id,granted,at:Date.now()});
if(granted)floors.add(id);else floors.delete(id);
$('parts').dataset.sig='';render();
return;
}
const btn=e.target.closest('[data-mute]');if(!btn||!room)return;
const id=btn.dataset.mute;
if(!confirm('Выключить микрофон участнику? Это увидят все — действие попадёт в протокол.'))return;
btn.disabled=true;
await publishSignal({v:SIGNAL_VERSION,type:'mute',target:id,at:Date.now()});
});
// Ведущий — тот, кто создал комнату (вошёл первым). Только у него кнопки глушения и
// завершения: иначе один вандал глушит всех и обрывает мероприятие.
function isOwner(){return !!room&&owner===room.localParticipant.identity;}
function applyOwner(){
const mine=isOwner();
$('finish').hidden=!mine;
$('ownernote').textContent=mine
? 'Вы ведущий этой комнаты: можете выключать микрофоны участникам и завершать созвон.'
: (ownerName?('Ведущий комнаты: '+ownerName+' — глушение микрофонов и завершение созвона у него.'):'');
$('ownernote').hidden=!$('ownernote').textContent;
$('parts').dataset.sig='';render();
}
// Что сейчас с агентом: люди не должны ловить тишину и гадать, думает он или отвалился.
const STATE_WORDS={hearing:'разбирает сказанное…',thinking:'думает…',speaking:'говорит',listening:'слушает',done:'закончил',
skipped:'решил не отвечать',error:'сбой у агента',floor:'на трибуне'};
function stateLine(identity){
const st=agentStates.get(identity);
if(!st)return '';
const stale=Date.now()-st.at>12000;
const key=stale?'lost':st.state;
const word=stale?'нет ответа':(STATE_WORDS[st.state]||st.state);
const floor=floors.has(identity)?' · трибуна':'';
return '<span class="state '+esc(key)+'">'+esc(word+floor)+'</span>';
}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
async function publishSignal(message){
if(!room)return;
const options={reliable:true};if(message.type==='chat')options.topic='chat:'+room.localParticipant.identity;
await room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)),options);
}
async function onData(payload,participant){
let m;try{m=JSON.parse(new TextDecoder().decode(payload));}catch{return;}
if(m.v!==SIGNAL_VERSION)return;
if(m.type==='transcript'&&typeof m.url==='string'){saveTranscript(m.url);return;}
if(m.type==='agentstate'&&participant){
agentStates.set(participant.identity,{state:m.state,floor:!!m.floor,at:Date.now()});
if(m.floor)floors.add(participant.identity);else floors.delete(participant.identity);
$('parts').dataset.sig='';render();return;
}
if(m.type==='floor'&&typeof m.target==='string'){
if(m.granted)floors.add(m.target);else floors.delete(m.target);
$('parts').dataset.sig='';render();return;
}
if(m.type==='owner'&&typeof m.identity==='string'){
owner=m.identity;ownerName=m.name||m.identity;applyOwner();return;
}
if(m.type==='mute'&&typeof m.target==='string'){
if(room&&m.target===room.localParticipant.identity){
muted=true;$('mute').textContent='🔇 Микрофон выключен';$('mute').classList.add('off');$('micnote').hidden=false;
$('status').textContent='Ваш микрофон выключил'+(participant?' '+(participant.name||participant.identity):' другой участник')+'. Включить обратно можно кнопкой «Микрофон».';
}
render();return;
}
if(m.type==='end'){
$('status').textContent='Созвон завершён'+(participant?' участником '+(participant.name||participant.identity):'')+'. Запись закрывается, транскрипт появится по ссылке.';
return;
}
if(m.type==='chat'&&participant&&typeof m.text==='string'){
addChat(participant,m.text.slice(0,2000),Number(m.at)||Date.now());return;
}
if(m.type==='hand-state-request'){
if(handRaisedAt!==null)await publishSignal({v:SIGNAL_VERSION,type:'hand',action:'status',raised:true,raisedAt:handRaisedAt});
return;
}
if(m.type!=='hand'||!participant)return;
if(m.raised)hands.set(participant.identity,Number(m.raisedAt));else hands.delete(participant.identity);
render();
}
function addChat(participant,text,at){
const log=$('chatlog'),atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<24;
const row=document.createElement('div');row.className='chatmsg';
row.innerHTML='<div class="chatmeta">'+(isBot(participant)?'🤖 ':'')+esc(participant.name||participant.identity)+' · '+esc(new Date(at).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'}))+'</div><div>'+esc(text)+'</div>';
log.appendChild(row);
if(atBottom){log.scrollTop=log.scrollHeight;}else{unread++;$('unread').textContent='Новых: '+unread;$('unread').hidden=false;}
}
$('chatlog').addEventListener('scroll',()=>{const e=$('chatlog');if(e.scrollHeight-e.scrollTop-e.clientHeight<24){unread=0;$('unread').hidden=true;}});
$('unread').onclick=()=>{$('chatlog').scrollTop=$('chatlog').scrollHeight;unread=0;$('unread').hidden=true;};
$('chatinput').addEventListener('keydown',async e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();const text=e.target.value.slice(0,2000);if(!text.trim()||!room)return;e.target.value='';const at=Date.now();await publishSignal({v:SIGNAL_VERSION,type:'chat',text,at,name:room.localParticipant.name||''});addChat(room.localParticipant,text,at);}});
setInterval(()=>{if(agentStates.size)$('parts').dataset.sig='';render();},1000);
async function lowerHand(){
if(handRaisedAt===null)return;
handRaisedAt=null;hands.delete(room.localParticipant.identity);
$('hand').textContent='✋ Поднять руку';$('hand').classList.remove('off');
await publishSignal({v:SIGNAL_VERSION,type:'hand',action:'change',raised:false,raisedAt:null});
}
$('mute').onclick=async()=>{
const want=muted; // хотим включить микрофон, если он сейчас выключен
try{
await room.localParticipant.setMicrophoneEnabled(want);
$('micdenied').hidden=true;
}catch(e){
if(want){$('micdenied').hidden=false;return;}
}
muted=!want;
$('mute').textContent=muted?'🔇 Микрофон выключен':'🎙 Микрофон включён';$('mute').classList.toggle('off',muted);$('micnote').hidden=!muted;
// Включил микрофон — значит собрался говорить: рука больше не нужна (Andy, 12.08).
if(!muted)await lowerHand();
render();
};
$('hand').onclick=async()=>{
handRaisedAt=handRaisedAt===null?Date.now():null;
if(handRaisedAt===null)hands.delete(room.localParticipant.identity);
else hands.set(room.localParticipant.identity,handRaisedAt);
await publishSignal({v:SIGNAL_VERSION,type:'hand',action:'change',raised:handRaisedAt!==null,raisedAt:handRaisedAt});
$('hand').textContent=handRaisedAt===null?'✋ Поднять руку':'✋ Рука поднята';$('hand').classList.toggle('off',handRaisedAt!==null);render();
};
// «Звук пропал» без разрыва сессии — жалоба kirill 12.08: помогал только перезаход.
// Дорожки переприкрепляются заново, оставаться в комнате при этом не нужно бросать.
function reattachAudio(){
if(!room)return;
document.querySelectorAll('[data-audio-sink]').forEach(el=>el.remove());
let n=0;
room.remoteParticipants.forEach(p=>p.audioTrackPublications.forEach(pub=>{
if(pub.track){const el=pub.track.attach();el.dataset.audioSink='1';document.body.appendChild(el);n++;}
}));
return n;
}
$('reaudio').onclick=()=>{
const n=reattachAudio();
$('netnote').textContent=n?('Звук переподключён — дорожек: '+n+'. Если тишина осталась, проверьте громкость и выбранное устройство вывода.')
:'Сейчас никто не передаёт звук — переподключать нечего.';
$('netnote').hidden=false;setTimeout(()=>{$('netnote').hidden=true;},6000);
};
$('leave').onclick=()=>room.disconnect();
$('finish').onclick=async()=>{
if(!room)return;
if(!confirm('Завершить созвон для всех? Запись закроется и уйдёт в расшифровку.'))return;
$('finish').disabled=true;
await publishSignal({v:SIGNAL_VERSION,type:'end',at:Date.now()});
$('status').textContent='Созвон завершён. Запись закрывается, транскрипт появится по ссылке.';
setTimeout(()=>room.disconnect(),700);
};
function saveTranscript(url){
if(localStorage.getItem(TRANSCRIPT_KEY)===url){showHistory();return;}
localStorage.setItem(TRANSCRIPT_KEY,url);let list=[];try{list=JSON.parse(localStorage.getItem(HISTORY_KEY)||'[]');}catch{}
list=[{room:ROOM,url,at:Date.now()},...list.filter(x=>x&&x.url!==url)].slice(0,10);localStorage.setItem(HISTORY_KEY,JSON.stringify(list));
$('transcriptLink').href=url;$('transcriptLink').textContent='Транскрипт этого созвона';$('transcript').hidden=false;applyArchiveLinks();showHistory();
}
function showHistory(){
let list=[];try{list=JSON.parse(localStorage.getItem(HISTORY_KEY)||'[]');}catch{}
$('history').innerHTML=list.length?'<h2>Транскрипты ваших созвонов</h2>'+list.slice(0,10).map(x=>'<a href="'+esc(x.url)+'" target="_blank" rel="noopener">«'+esc(x.room)+'» — '+new Date(x.at).toLocaleString('ru-RU')+'</a>').join(''):'';
const saved=localStorage.getItem(TRANSCRIPT_KEY);if(saved){$('transcriptLink').href=saved;$('transcriptLink').textContent='Транскрипт этого созвона';$('transcript').hidden=false;}
applyArchiveLinks();
}
$('copyTranscript').onclick=async()=>{await navigator.clipboard.writeText($('transcriptLink').href);$('copyTranscript').textContent='Скопировано';setTimeout(()=>$('copyTranscript').textContent='Скопировать',1200);};
</script></body></html>