Files

300 lines
26 KiB
JavaScript
Raw Permalink 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.
"use strict";
const $=s=>document.querySelector(s);
const LS={get(k,d){try{const v=localStorage.getItem("sdr."+k);return v===null?d:JSON.parse(v)}catch(e){return d}},
set(k,v){try{localStorage.setItem("sdr."+k,JSON.stringify(v))}catch(e){}}};
const GROUPS=__GROUPS__;
let st={voice:LS.get("voice",true),auto:LS.get("auto",true),newer:LS.get("newer",true),skip:LS.get("skip",false),
live:LS.get("live",false),groups:LS.get("groups",[]),speed:LS.get("speed",1)};
let items=[],view=[],cur=null,limit=400,loading=false,liveQ=[],fresh=new Set(),allCounts=null,older=[],hasMore=false,selChan=null,statsOpen=false;
let es=null,sseOk=false,lastMsg=0,pollT=null;
const au=$("#au"),cv=$("#cv");
au.playbackRate=st.speed;
function sw(id,key,cb){const el=$(id);const paint=()=>el.classList.toggle("on",!!st[key]);paint();
el.onclick=()=>{st[key]=!st[key];LS.set(key,st[key]);paint();cb&&cb()};}
(()=>{const el=$("#sw-voice");const paint=()=>el.classList.toggle("on",!st.voice);paint();
el.onclick=()=>{st.voice=!st.voice;LS.set("voice",st.voice);paint();refresh(true)}})();
sw("#sw-auto","auto");
sw("#sw-dir","newer",()=>{$("#dirtxt").textContent=st.newer?"к новым":"к старым"});
$("#dirtxt").textContent=st.newer?"к новым":"к старым";
sw("#sw-live","live",()=>{liveQ=[]});
sw("#sw-skip","skip");
// speakers on the Pi: state lives on the server; the switch exists only for clients on the home LAN
(async()=>{const el=$("#sw-spk"),vol=$("#spk-vol");let s;
const paint=()=>{el.classList.toggle("on",!!s.on);vol.value=s.volume;vol.hidden=!s.on};
const post=async(b)=>{try{const r=await fetch("/api/speaker",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});
if(r.ok){s=await r.json();paint()}else msg("Колонки можно переключать только из домашней сети")}catch(e){msg("Нет связи с сервером")}};
try{s=await (await fetch("/api/speaker",{cache:"no-store"})).json()}catch(e){return}
if(!s.local)return;
el.hidden=false;paint();
el.onclick=()=>post({on:!s.on});
vol.onchange=()=>post({volume:+vol.value})})();
function fmtT(s){if(!isFinite(s))s=0;s=Math.max(0,s);const m=Math.floor(s/60);return m+":"+String(Math.floor(s%60)).padStart(2,"0")}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]))}
function dayName(d){const dt=new Date(d+"T12:00:00");const now=new Date();const y=new Date(now-864e5);
const same=(a,b)=>a.toDateString()===b.toDateString();
const s=dt.toLocaleDateString("ru-RU",{day:"numeric",month:"long",weekday:"short"});
return same(dt,now)?"Сегодня, "+s:same(dt,y)?"Вчера, "+s:s}
function msg(t){const m=$("#msg");if(!t){m.hidden=true;return}m.hidden=false;m.innerHTML=t}
function passes(it){return (!st.groups.length||st.groups.includes(it.group))&&(!selChan||Math.abs(it.freq_mhz*1e6-selChan)<500)}
const listQ=()=>`voice=${st.voice?1:0}&groups=${encodeURIComponent(st.groups.join(","))}${selChan?`&chan=${selChan}`:""}`;
function wanted(it){return st.voice?it.class==="voice":true}
const playerFree=()=>!cur||au.ended;
function onNew(it){if(!st.live)return;if(playerFree())play(it);else if(liveQ.length<20&&!liveQ.some(x=>x.id===it.id))liveQ.push(it)}
function wfSvg(pk,w,h){if(!pk||!pk.length)return "";const n=Math.min(pk.length,60),step=pk.length/n;let r="";
const bw=w/n;for(let i=0;i<n;i++){let m=0;for(let j=Math.floor(i*step);j<Math.floor((i+1)*step);j++)m=Math.max(m,pk[j]);
const bh=Math.max(1,m/99*h);r+=`<rect x="${(i*bw).toFixed(1)}" y="${((h-bh)/2).toFixed(1)}" width="${Math.max(1,bw-1).toFixed(1)}" height="${bh.toFixed(1)}"/>`}
return `<svg class="wf" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" fill="#3a3a3a">${r}</svg>`}
function renderChips(){const counts=(allCounts&&allCounts.groups)||{}; // all time, from the server
let h=`<span class="chip ${st.groups.length?"":"on"}" data-g="">Все<span class="n">${allCounts?allCounts.total:items.length}</span></span>`;
for(const [k,v] of Object.entries(GROUPS)){const on=st.groups.includes(k);
h+=`<span class="chip ${k} ${on?"on":""}" data-g="${k}">${esc(v)}<span class="n">${counts[k]||0}</span></span>`}
$("#chips").innerHTML=h;$("#chips").classList.toggle("chips-sel",st.groups.length>0);
for(const c of $("#chips").children)c.onclick=()=>{const g=c.dataset.g;
if(!g)st.groups=[];else st.groups=st.groups.includes(g)?st.groups.filter(x=>x!==g):[...st.groups,g];
LS.set("groups",st.groups);older=[];selChan=null;renderChips();renderStats();refresh(true);window.scrollTo(0,0)}}
// frequencies, all time: a panel beside the list (phone: a strip of pills above it); a click filters in place
function renderStats(){const E=$("#stats");if(!E)return;const ch=(allCounts&&allCounts.chans)||[];
const vis=ch.filter(c=>!st.groups.length||st.groups.includes(c.group));
if(!vis.length){E.innerHTML="";return}
const max=Math.max(1,...vis.map(c=>c.n)),tot=vis.reduce((s,c)=>s+c.n,0);
const row=c=>{const on=c.f===selChan,w=(100*c.n/max).toFixed(1);
return `<button class="fq-r ${esc(c.group)}${on?" on":""}" data-f="${c.f}" title="${esc(c.label)} · ${(c.f/1e6).toFixed(4)} МГц · ${c.n} записей${on?" · нажмите, чтобы снять фильтр":""}">`+
`<span class="fq-n"><b>${esc(c.label)}</b><i>${(c.f/1e6).toFixed(4)}</i></span><span class="fq-c">${on?"✕":c.n}</span>`+
`<span class="fq-b"><i class="bar" style="width:${w}%"></i></span></button>`};
E.innerHTML=`<div class="fq-h"><b>Частоты</b><span>${tot} зап. · ${vis.length} источн. · за всё время</span></div>`+
`<div class="fq-l"><button class="fq-r all${selChan?"":" on"}" data-f=""><span class="fq-n"><b>Все частоты</b></span><span class="fq-c">${tot}</span></button>${vis.map(row).join("")}</div>`;
for(const b of E.querySelectorAll(".fq-r"))b.onclick=()=>{const f=+b.dataset.f||null;selChan=selChan===f?null:f;older=[];renderStats();refresh(true);
// keep the page where it is; only when the list top is above the screen, bring it into view
const L=$("#list"),t=L.getBoundingClientRect().top;if(t<0)window.scrollBy({top:t-90,behavior:"smooth"})};
// phone: bring the selected pill to the middle of the strip -- the strip scrolls, the page does not
const on=E.querySelector(".fq-r.on:not(.all)"),L2=E.querySelector(".fq-l");
if(on&&L2&&matchMedia("(max-width:1099px)").matches)L2.scrollLeft=on.offsetLeft-L2.clientWidth/2+on.offsetWidth/2}
function rowHtml(it){const gname=GROUPS[it.group]||it.group;
return `<div class="row ${it.class!=="voice"?"noise":""} ${cur&&cur.id===it.id?"cur":""} ${fresh.has(it.id)?"new":""}" data-f="${esc(it.id)}">`+
`<span class="t">${it.ts.slice(11,19)}</span>`+
`<span class="ch"><span class="g ${esc(it.group)}">${esc(gname)}</span><b>${esc(it.channel)}</b><span class="fq">${it.freq_mhz.toFixed(4)}</span>${it.archived?'<span class="tag" style="color:#7cc4ff;border-color:#335">архив</span>':""}${it.class==="noise"?'<span class="tag">отбраковано</span>':it.class==="pending"?'<span class="tag" style="color:#888;border-color:#555">…</span>':""}</span>`+
wfSvg(it.peaks,150,22)+
`<span class="d">${it.duration_s!=null?it.duration_s.toFixed(1)+" с":"?"}</span>`+
`<a class="dl" href="${esc(it.url)}?dl=1" title="Скачать" onclick="event.stopPropagation()">&#11015;</a>`+acHtml(it)+trHtml(it)+`</div>`}
function acChip(a){const alt=a.alt==null?"":a.alt===0?"на земле":(a.alt>=10000?"FL"+Math.round(a.alt/100):Math.round(a.alt/100)*100+" ft");
return `<span class="ac ${a.emg?"emg":a.ulli?"ulli":""}" title="${esc([a.t,a.sq?("squawk "+a.sq):"",a.gs?Math.round(a.gs)+" kt":"",a.trk!=null?"курс "+Math.round(a.trk)+"°":"",a.cat].filter(Boolean).join(" · "))}">${a.emg?"⚠ ":""}${esc(a.cs||a.hex||"?")} ${alt} ${a.d} nm ${a.b}°${a.emg?" "+esc(a.sq||""):""}</span>`}
function trHtml(it){
if(it.tr)return `<div class="trx${it.tr_lc?" lc":""}"${it.tr_lc?' title="низкая уверенность распознавания"':""}>${it.tr_l&&it.tr_l!=="ru"?`<span class="lg">${esc(it.tr_l.toUpperCase())}</span>`:""}${esc(it.tr)}</div>`;
if(it.tr_q)return `<div class="trx q">расшифровка в очереди…</div>`;
if(it.tr_none)return `<div class="trx q">речь не разобрана</div>`;
return ""}
function paintTt(){const e=$("#tt");if(!e)return;const it=cur;e.className="";e.textContent="";if(!it)return;
if(it.tr){if(it.tr_lc)e.className="lc";e.textContent=it.tr}else if(it.tr_q){e.className="q";e.textContent="расшифровка в очереди…"}}
const trSig=x=>x.id+x.class+(x.tr||"")+(x.tr_q?"q":"")+(x.tr_none?"n":"");
function acHtml(it){const ac=it.ac;if(!ac||!ac.list||!ac.list.length)return "";
const l=ac.list.slice(0,4);return `<span class="acl" title="ADS-B в момент записи (${esc(ac.src||"")}) — контекст, а не доказательство, кто говорил">${l.map(acChip).join("")}${ac.list.length>4?`<span class="ac">+${ac.list.length-4}</span>`:""}</span>`}
const CONV_GAP=30;
function tsec(it){return Date.parse(it.ts)/1000}
// group recordings on the same frequency whose gaps are < CONV_GAP s (arr is newest-first)
function convBlocks(arr){const blocks=[],open=new Map();
for(const it of arr){const k=it.freq_mhz;const g=open.get(k);
if(g){const older=it,newer=g[g.length-1];const gap=tsec(newer)-(tsec(older)+(older.duration_s||0));
if(gap<CONV_GAP){g.push(it);continue}}
const nb=[it];blocks.push(nb);open.set(k,nb)}
return blocks}
function holdFor(it){const long=(it.act!=null?it.act:it.duration_s||0)>=0.8;return ((long&&it.hold_ms)?it.hold_ms:2000)/1000}
function gapHtml(newer,older){const gap=tsec(newer)-(tsec(older)+(older.duration_s||0));const g=Math.max(0,gap);
const hold=holdFor(older);
if(g>hold+0.5)return `<div class="gap miss">пауза ${g.toFixed(1).replace(".",",")} с · сканер уходил — мог пропустить ответ (≈${(g-hold).toFixed(0)} с)</div>`;
return `<div class="gap">пауза ${g.toFixed(1).replace(".",",")} с</div>`}
function render(){
// keep the viewport anchored to the first visible row across re-renders (new rows above do not push the view)
let anchor=null,off=0;
if(window.scrollY>40){for(const r of document.querySelectorAll(".row")){const b=r.getBoundingClientRect();if(b.bottom>90){anchor=r.dataset.f;off=b.top;break}}}
view=items.filter(passes);
// display order = conversation blocks; prev/next follow what is on screen
{const disp=[];const days0=new Map();for(const it of view){const d=it.id.slice(0,10);if(!days0.has(d))days0.set(d,[]);days0.get(d).push(it)}
for(const arr of days0.values())for(const b of convBlocks(arr))disp.push(...b);view=disp}
const L=$("#list");
if(!view.length){L.innerHTML=`<p class="empty">${items.length?"Нет записей в выбранных группах.":(st.voice?"Пока нет записей с голосом.":"Записей пока нет.")}</p>`;return}
const days=new Map();for(const it of view){const d=it.id.slice(0,10);if(!days.has(d))days.set(d,[]);days.get(d).push(it)}
let h="";
for(const [d,arr] of days){const per={};for(const it of arr)per[it.channel]=(per[it.channel]||0)+1;
const top=Object.entries(per).sort((a,b)=>b[1]-a[1]);
const gc={};for(const it of arr)gc[it.group]=(gc[it.group]||0)+1;
const seg=Object.entries(gc).sort((a,b)=>b[1]-a[1]).map(([g,n])=>`<i class="${esc(g)}" style="flex:${n}" title="${esc(GROUPS[g]||g)}: ${n}"></i>`).join("");
h+=`<section class="day"><div class="dh"><h2>${esc(dayName(d))}</h2><span class="cnt"><span><b>${arr.length}</b> зап.</span><span class="dbar">${seg}</span>`+
`<span class="top3">${top.slice(0,3).map(([c,n])=>`${esc(c)} <b>${n}</b>`).join(" · ")}${top.length>3?` · ещё ${top.length-3}`:""}</span></span></div>`;
for(const b of convBlocks(arr)){
if(b.length<2){h+=rowHtml(b[0]);continue}
const span=(tsec(b[0])+(b[0].duration_s||0))-tsec(b[b.length-1]);
h+=`<div class="conv"><div class="ch2"><span>разговор · ${esc(b[0].channel)} · ${b.length} перед. · ${fmtT(span)}</span><button data-conv="${esc(b[b.length-1].id)}">&#9654; слушать разговор</button></div>`;
b.forEach((it,k)=>{h+=rowHtml(it);if(k<b.length-1)h+=gapHtml(it,b[k+1])});
h+="</div>"}
h+="</section>"}
if(hasMore)h+=`<button id="more">Показать ещё</button>`;
L.innerHTML=h;
const m=$("#more");if(m){m.onclick=()=>loadOlder(m);
// infinite scroll: the next page (older days, archived ones too) loads by itself near the bottom
if("IntersectionObserver" in window){if(window._moreIO)window._moreIO.disconnect();
window._moreIO=new IntersectionObserver(es=>{if(es.some(e=>e.isIntersecting)&&!m.disabled)loadOlder(m)},{rootMargin:"800px"});window._moreIO.observe(m)}}
if(anchor){const r=document.querySelector(`.row[data-f="${CSS.escape(anchor)}"]`);if(r)window.scrollBy(0,r.getBoundingClientRect().top-off)}
}
let convQ=[];
$("#list").addEventListener("click",e=>{const cb=e.target.closest("button[data-conv]");
if(cb){e.stopPropagation();const blk=cb.closest(".conv");const ids=[...blk.querySelectorAll(".row")].map(r=>r.dataset.f).reverse();
const its=ids.map(id=>view.find(x=>x.id===id)).filter(Boolean);if(its.length){convQ=its.slice(1);play(its[0])}return}},true);
$("#list").addEventListener("click",e=>{const r=e.target.closest(".row");if(!r)return;convQ=[];
const it=view.find(x=>x.id===r.dataset.f);if(!it)return;
if(cur&&cur.id===it.id){au.paused?au.play().catch(()=>{}):au.pause();return}
play(it)});
// mode: "center" (deep link), "follow" (user/auto-advance started it: keep it visible), none (re-render: never scroll)
function markCur(mode){for(const r of document.querySelectorAll(".row.cur"))r.classList.remove("cur");
if(!cur)return;const r=document.querySelector(`.row[data-f="${CSS.escape(cur.id)}"]`);
if(r){r.classList.add("cur");if(!mode)return;const b=r.getBoundingClientRect();const pb=$("#pl").getBoundingClientRect().top;
if(mode==="center"||b.bottom>pb||b.top<110)r.scrollIntoView({block:"center",behavior:mode==="center"?"auto":"smooth"})}}
function select(it){cur=it;
// one player only: selecting/playing a recording always replaces whatever was playing
au.src=it.url;au.playbackRate=st.speed;
$("#ti").innerHTML=`<span class="g ${esc(it.group)}">${esc(GROUPS[it.group]||it.group)}</span><b>${esc(it.channel)}</b><span class="m">${it.freq_mhz.toFixed(4)} МГц · ${esc(dayName(it.id.slice(0,10)))} ${it.ts.slice(11,19)}${it.class==="noise"?" · помечена как шум":""}</span>`;
$("#dlc").href=it.url+"?dl=1";paintTt();
try{history.replaceState(null,"","#/"+it.id)}catch(e){}
if("mediaSession" in navigator){try{navigator.mediaSession.metadata=new MediaMetadata({title:it.channel,artist:it.freq_mhz.toFixed(4)+" МГц",album:it.ts.slice(0,19).replace("T"," ")})}catch(e){}}
draw()}
function play(it){$("#big").hidden=true;msg("");select(it);au.play().catch(()=>{$("#big").hidden=false});markCur("follow")}
function idx(){return cur?view.findIndex(x=>x.id===cur.id):-1}
function step(d){ // d=+1 older (down the list), -1 newer
if(!view.length)return false;let i=idx();
if(i<0){if(!cur){play(view[0]);return true}
const t=cur.ts;let j=-1;
if(d>0)j=view.findIndex(x=>x.ts<t);else view.forEach((x,k)=>{if(x.ts>t)j=k});
if(j>=0){play(view[j]);return true}return false}
const j=i+d;if(j<0||j>=view.length)return false;play(view[j]);return true}
$("#prev").onclick=()=>step(-1);$("#next").onclick=()=>step(1);
$("#pp").onclick=()=>{if(!cur){if(view.length)play(view[0]);return}$("#big").hidden=true;au.paused?au.play().catch(()=>{}):au.pause()};
$("#big").onclick=()=>{$("#big").hidden=true;au.play().catch(()=>{})};
const speeds=[1,1.5,2];
function paintSpd(){$("#spd").textContent=String(st.speed).replace(".",",")+"×";$("#spd").classList.toggle("on",st.speed!==1)}
paintSpd();
$("#spd").onclick=()=>{st.speed=speeds[(speeds.indexOf(st.speed)+1)%speeds.length];au.playbackRate=st.speed;LS.set("speed",st.speed);paintSpd()};
$("#cp").onclick=async()=>{if(!cur)return;let url,ok=false;
// share link: /s/<random token> -- opens this recording and its conversation without the site password
try{const r=await fetch("/api/share",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:cur.id})});url=location.origin+(await r.json()).url}
catch(e){url=location.origin+location.pathname+"#/"+cur.id}
try{await navigator.clipboard.writeText(url);ok=true}catch(e){}
if(!ok){const ta=document.createElement("textarea");ta.value=url;ta.style.position="fixed";ta.style.opacity="0";document.body.appendChild(ta);ta.select();
try{ok=document.execCommand("copy")}catch(e){}ta.remove()}
const b=$("#cp");b.classList.add("on");b.querySelector(".lbl").textContent=ok?" скопировано":" не удалось";
if(!ok)prompt("Ссылка на запись:",url);
setTimeout(()=>{b.classList.remove("on");b.querySelector(".lbl").textContent=" ссылка"},1500)};
au.addEventListener("play",()=>{$("#pp").innerHTML="&#10074;&#10074;";$("#big").hidden=true});
au.addEventListener("pause",()=>{$("#pp").innerHTML="&#9654;"});
au.addEventListener("ended",()=>{$("#pp").innerHTML="&#9654;";
if(convQ.length){const n=convQ.shift();play(n);return}
if(st.live&&liveQ.length){play(liveQ.shift());return}
if(!st.auto)return;step(st.newer?-1:1)});
au.addEventListener("timeupdate",()=>{
$("#tm").textContent=fmtT(au.currentTime)+" / "+fmtT(au.duration||(cur&&cur.duration_s)||0);draw();
if(st.skip&&cur&&cur.peaks&&cur.peaks.length&&isFinite(au.duration)&&!au.paused){
const pk=cur.peaks,n=pk.length,D=au.duration,k=Math.floor(au.currentTime/D*n);
if(k<n&&pk[k]<=1){let j=k;while(j<n&&pk[j]<=1)j++;const gap=(j-k)*D/n;if(gap>0.6)au.currentTime=Math.min(D-0.05,j*D/n)}}});
au.addEventListener("error",async()=>{if(!cur||!au.src)return;const id=cur.id;
try{const r=await fetch("/api/item?id="+encodeURIComponent(id),{cache:"no-store"});
msg(r.status===404?`Запись удалена (шум удаляется через 2 дня; при нехватке места — самые старые): ${esc(id)}`:`Не удалось загрузить запись (архив на сервере недоступен?): ${esc(id)}`)}
catch(e){msg("Нет связи с сервером")}});
function draw(){const r=cv.getBoundingClientRect(),dpr=window.devicePixelRatio||1;
if(cv.width!==Math.round(r.width*dpr)){cv.width=Math.round(r.width*dpr);cv.height=Math.round(r.height*dpr)}
const c=cv.getContext("2d"),W=cv.width,H=cv.height;c.clearRect(0,0,W,H);
const pk=cur&&cur.peaks&&cur.peaks.length?cur.peaks:null,D=au.duration||(cur&&cur.duration_s)||0,p=D?au.currentTime/D:0;
if(pk){const n=pk.length,bw=W/n;for(let i=0;i<n;i++){const bh=Math.max(1*dpr,pk[i]/99*(H-4*dpr));
c.fillStyle=(i+0.5)/n<=p?"#3ddc84":"#333";c.fillRect(i*bw,(H-bh)/2,Math.max(1,bw-1*dpr),bh)}}
else{c.fillStyle="#222";c.fillRect(0,H/2-2*dpr,W,4*dpr);c.fillStyle="#3ddc84";c.fillRect(0,H/2-2*dpr,W*p,4*dpr)}
c.fillStyle="#f2f2f2";c.fillRect(Math.round(W*p)-dpr,0,2*dpr,H)}
window.addEventListener("resize",draw);
(function(){const s=$("#seek");let drag=false;
const go=e=>{if(!cur||e.target.id==="big")return;const D=au.duration||cur.duration_s;if(!D)return;const r=s.getBoundingClientRect();
au.currentTime=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width))*D*0.9999;draw()};
s.addEventListener("pointerdown",e=>{if(e.target.id==="big")return;drag=true;s.setPointerCapture(e.pointerId);go(e)});
s.addEventListener("pointermove",e=>{if(drag)go(e)});
s.addEventListener("pointerup",()=>{drag=false})})();
document.addEventListener("keydown",e=>{if(e.ctrlKey||e.metaKey||e.altKey)return;
const t=e.target.tagName;if(t==="INPUT"||t==="TEXTAREA")return;
if(e.code==="Space"){e.preventDefault();$("#pp").click()}
else if(e.key==="j"||(e.key==="ArrowRight"&&!e.shiftKey)){e.preventDefault();step(1)}
else if(e.key==="k"||(e.key==="ArrowLeft"&&!e.shiftKey)){e.preventDefault();step(-1)}
else if(e.key==="ArrowRight"&&e.shiftKey){au.currentTime=Math.min((au.duration||0)-0.05,au.currentTime+5)}
else if(e.key==="ArrowLeft"&&e.shiftKey){au.currentTime=Math.max(0,au.currentTime-5)}});
if("mediaSession" in navigator){try{navigator.mediaSession.setActionHandler("previoustrack",()=>step(-1));navigator.mediaSession.setActionHandler("nexttrack",()=>step(1))}catch(e){}}
function paintStatus(s,pending){if(!s)return;const ok=s.ok;let a="";
if(s.last_recording_age_s!=null){const m=Math.round(s.last_recording_age_s/60);a=m<1?"только что":m<60?m+" мин назад":Math.round(m/60)+" ч назад"}
$("#st").innerHTML=(ok?"":`<b class="bad">сканер: ${esc(s.scanner)}</b> · `)+`${s.voice_last_hour} голос за час`+(a?` · последняя ${a}`:"")+(pending?` · в обработке ${pending}`:"")+trStatus(s.transcribe)}
function trStatus(t){if(!t)return "";if(t.stale)return ` · <b class="bad">расшифровка не работает</b>`;
return t.pending?` · расшифровка: ${t.pending} в очереди${t.endpoint_up?"":" (ждёт ПК)"}`:` · расшифровано ${t.done}`}
function paintLive(){$("#live").textContent=sseOk?"":"обновление раз в 5 с"}
// ---- deep link #/DAY/FILE ----
let deep=null;
function parseHash(){const h=decodeURIComponent(location.hash||"");const m=h.match(/^#\/(\d{4}-\d{2}-\d{2}\/[A-Za-z0-9_-]+\.mp3)$/);return m?m[1]:null}
async function openDeep(id){deep=null;if(!$("#src").hidden)setTab("rec");if(!id||(cur&&cur.id===id))return;
let it=items.find(x=>x.id===id);
if(!it){try{const r=await fetch("/api/item?id="+encodeURIComponent(id),{cache:"no-store"});
if(r.status===404){msg(`Запись удалена (шум удаляется через 2 дня; при нехватке места — самые старые): ${esc(id)}`);return}
it=await r.json()}catch(e){msg("Не удалось открыть запись по ссылке");return}}
if(it.class!=="voice"&&st.voice&&!items.find(x=>x.id===id))msg("Эта запись помечена классификатором как шум — она скрыта, пока выключено «показать отбракованные».");
select(it);markCur("center");
au.play().then(()=>{$("#big").hidden=true}).catch(()=>{$("#big").hidden=false})}
window.addEventListener("hashchange",()=>{const id=parseHash();if(id)openDeep(id)});
// ---- live: SSE push + polling fallback ----
function addLive(it){
if(!wanted(it))return;
if(items.some(x=>x.id===it.id))return;
let i=items.findIndex(x=>x.ts<it.ts);if(i<0)i=items.length;items.splice(i,0,it);
fresh.add(it.id);setTimeout(()=>{fresh.delete(it.id);const r=document.querySelector(`.row[data-f="${CSS.escape(it.id)}"]`);if(r)r.classList.remove("new")},4000);
renderChips();render();markCur();
if(passes(it)){
onNew(it)}}
function startSSE(){if(!window.EventSource)return;
try{es=new EventSource("/api/events")}catch(e){return}
es.addEventListener("open",()=>{sseOk=true;lastMsg=Date.now();paintLive();schedulePoll()});
es.addEventListener("rec",e=>{lastMsg=Date.now();try{addLive(JSON.parse(e.data))}catch(x){}});
es.addEventListener("status",e=>{lastMsg=Date.now();try{paintStatus(JSON.parse(e.data),null)}catch(x){}});
es.addEventListener("error",()=>{sseOk=false;paintLive();schedulePoll();
if(es.readyState===2){setTimeout(startSSE,10000)}})}
setInterval(()=>{if(sseOk&&Date.now()-lastMsg>40000){sseOk=false;paintLive();schedulePoll();try{es.close()}catch(e){}startSSE()}},10000);
function schedulePoll(){clearInterval(pollT);pollT=setInterval(()=>{if(!document.hidden)refresh(false)},sseOk?60000:5000)}
// next page of the current filter: everything older than the last loaded recording, any day back
async function loadOlder(btn){if(!items.length)return;btn.disabled=true;btn.textContent="загружаю…";
try{const last=items[items.length-1].id;
const r=await fetch(`/api/list?${listQ()}&limit=400&before=${encodeURIComponent(last)}`,{cache:"no-store"});
const j=await r.json();const have=new Set(items.map(x=>x.id));
const add=j.items.filter(x=>!have.has(x.id));older=older.concat(add);items=items.concat(add);hasMore=j.more;
if(j.counts)allCounts=j.counts;renderChips();renderStats();render()}
catch(e){btn.disabled=false;btn.textContent="Показать ещё"}}
async function refresh(force){if(loading)return;loading=true;
try{const r=await fetch(`/api/list?${listQ()}&limit=${limit}`,{cache:"no-store"});const j=await r.json();
if(j.counts)allCounts=j.counts;
{const first=new Set(j.items.map(x=>x.id));j.items=j.items.concat(older.filter(x=>!first.has(x.id)));} // keep pages loaded with "more"
hasMore=older.length?hasMore:j.more;
const sig=j.items.map(trSig).join("|");
const changed=force||sig!==items.map(trSig).join("|");
if(changed){const had=new Set(items.map(x=>x.id));const firstLoad=!items.length;
const added=j.items.filter(x=>!had.has(x.id));items=j.items;
if(!firstLoad&&!force)for(const it of added){fresh.add(it.id);setTimeout(()=>fresh.delete(it.id),4000)}
if(cur){const n=items.find(x=>x.id===cur.id);if(n){n.peaks=n.peaks&&n.peaks.length?n.peaks:cur.peaks;cur.class=n.class;for(const k of ["tr","tr_l","tr_lc","tr_q","tr_none"])cur[k]=n[k];paintTt()}}
renderChips();renderStats();render();markCur();
if(!firstLoad&&!force)for(const it of added.slice().reverse())if(passes(it)){
onNew(it)}}
renderStats();paintStatus(j.status,j.pending)}
catch(e){$("#st").innerHTML='<b class="bad">нет связи с сервером</b>'}
finally{loading=false}}
(async()=>{await refresh(true);const id=parseHash();if(id)openDeep(id);paintLive();schedulePoll();startSSE()})();
document.addEventListener("visibilitychange",()=>{if(!document.hidden)refresh(false)});