Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
26 KiB
JavaScript
300 lines
26 KiB
JavaScript
"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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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()">⬇</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)}">▶ слушать разговор</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="❚❚";$("#big").hidden=true});
|
||
au.addEventListener("pause",()=>{$("#pp").innerHTML="▶"});
|
||
au.addEventListener("ended",()=>{$("#pp").innerHTML="▶";
|
||
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)});
|
||
|