Сервис аудиосозвонов с автоматическим протоколом, работает с 10 августа 2026 года. Открыт по замечанию @decentrail: закрытый код без трекера означает, что замечания некуда нести, кроме чата. Не входит: конфигурация с ключами, записи созвонов, модели синтеза речи.
340 lines
26 KiB
HTML
340 lines
26 KiB
HTML
<!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>
|