Files
radio-explorer/server/public/assets/kiosk-DIx-PLJP.js
Marco Mooren 29423288ca feat: add multi-user support for favorites management and room clock synchronization
- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
2026-05-13 13:53:12 +02:00

2 lines
29 KiB
JavaScript

import"./modulepreload-polyfill-B5Qt9EMX.js";import{P as we,a as f,m as Ce,c as ee,e as a,l as z,i as E}from"./debug-DBzSAgZo.js";import{R as $e,c as Ae,a as Me,s as ke}from"./playGate-C1e0nYli.js";const x=document.getElementById("app");function _e(){const e=localStorage.getItem("oradio.mode");return e==="solo"||e==="synced"?e:e==="linked"?(localStorage.setItem("oradio.mode","synced"),localStorage.setItem("oradio.syncedAudio","1"),"synced"):e==="remote"||e==="follow-room"?(localStorage.setItem("oradio.mode","synced"),localStorage.setItem("oradio.syncedAudio","0"),"synced"):(e==="play-here"&&localStorage.setItem("oradio.mode","solo"),"solo")}function xe(){const e=localStorage.getItem("oradio.syncedAudio");return e!=="0"}function ce(){const e=Number(localStorage.getItem("oradio.localVolume"));return Number.isFinite(e)&&e>=0&&e<=1?e:.7}function Ne(){try{const e=localStorage.getItem("oradio.lastStation");if(!e)return null;const o=JSON.parse(e);if(o&&Number.isFinite(o.id))return o}catch{}return null}function ue(e){if(!(!e||!Number.isFinite(e.id)))try{localStorage.setItem("oradio.lastStation",JSON.stringify({id:e.id,name:e.name,genres:e.genres||[]}))}catch{}}const t={user:null,tab:"favorites",stations:[],categories:[],selectedCategory:null,favorites:[],history:[],query:"",sort:"hot",randomMode:localStorage.getItem("oradio.randomMode")==="favorites"?"favorites":"all",rooms:[],roomSlug:localStorage.getItem("oradio.room")||null,mode:_e(),syncedAudio:xe(),roomState:null,roomPeers:[],roomDevices:{list:[],current:null},player:{stationId:null,stationName:null,genres:[],playing:!1,loading:!1,volume:ce(),votes:null},sync:{status:"off",error:0,rtt:null,bufferMs:8e3,delay:0},pendingCommand:null,session:null};let $=null;function _(e){t.pendingCommand=e,$&&(clearTimeout($),$=null),e&&($=setTimeout(()=>{t.pendingCommand=null,$=null,l()},2e3))}function Le(){t.pendingCommand==null&&$==null||(t.pendingCommand=null,$&&(clearTimeout($),$=null))}const T=new $e,p=new we({onState:e=>{if(t.player={...t.player,...e},typeof e.volume=="number")try{localStorage.setItem("oradio.localVolume",String(e.volume))}catch{}pe()}});let W=!1;function pe(){W||(W=!0,requestAnimationFrame(()=>{W=!1,l()}))}let D=0;function ie(e){return e instanceof HTMLInputElement&&e.type==="range"}if(typeof window<"u"){document.addEventListener("pointerdown",o=>{ie(o.target)&&D++},!0);const e=o=>{ie(o.target)&&D>0&&(D--,pe())};document.addEventListener("pointerup",e,!0),document.addEventListener("pointercancel",e,!0)}let H=null,j=null;function Pe(e){j=e,!H&&(H=setTimeout(()=>{H=null;const o=j;j=null,m==null||m.send({type:"command",action:"volume",value:o})},80))}let J=null,K=null;function Te(e){K=e,!J&&(J=setTimeout(()=>{J=null;const o=K;K=null,m==null||m.send({type:"command",action:"setSyncBuffer",value:o})},150))}p.setLocalVolume(ce());p.onSyncChange=e=>{t.sync={status:e.status,error:e.error,rtt:e.clockRtt,bufferMs:e.bufferMs,delay:e.delay},l()};let m,P=!1,X=!1;function Y(){P=!0,X=!1}async function me(e,o){if(P)return!0;if(Me())return P=!0,!0;if(X)return!1;try{return await ke({stationName:e||"Radio",subtitle:o||"Tap Start to enable audio.",onStart:()=>{P=!0}}),P}catch{return X=!0,!1}}let F=null;function te(){if(F)try{F.abort()}catch{}return F=typeof AbortController<"u"?new AbortController:null,F}async function ye(){try{t.user=await f.get("/api/auth/me")}catch{Ve();return}await oe();try{t.rooms=await f.get("/api/rooms"),(!t.roomSlug||!t.rooms.find(e=>e.slug===t.roomSlug))&&(t.roomSlug=t.rooms[0]&&t.rooms[0].slug||`u-${t.user.id}`,localStorage.setItem("oradio.room",t.roomSlug))}catch{t.roomSlug=t.roomSlug||`u-${t.user.id}`}if(O(),Ce({player:p,clock:T,getWs:()=>m,role:"kiosk"}),l(),Ye(),t.mode==="solo"){const e=Ne();if(e&&Number.isFinite(e.id))try{const o=t.favorites.find(n=>n.id===e.id)||t.stations.find(n=>n.id===e.id)||await f.get(`/api/stations/${e.id}`).catch(()=>null);o&&await Re(o)}catch{}}}async function Re(e){const o=te();if(t.player={...t.player,stationId:e.id,stationName:e.name,genres:e.genres||[],playing:!1,loading:!0,error:null},l(),!await me(e.name,"Tap Start to resume your last station.")){t.player.loading=!1,t.player.playing=!1,l();return}await p.play(e),p.audio.paused&&p.audio.play().catch(()=>{}),ae(e.id);try{const s=await f.post(`/api/stations/${e.id}/play`,null,o?{signal:o.signal}:void 0);t.player.stationId===e.id&&(t.player.votes=s,s.sessionId&&(t.session={id:s.sessionId,stationId:e.id,startedAt:Date.now()}),N(e.id,s),l())}catch(s){E(s)}}function O(){if(m)try{m.close()}catch{}T.detach();const e=t.mode==="synced"&&!t.syncedAudio?"panel":"controller";z("openWs",{mode:t.mode,syncedAudio:t.syncedAudio,kind:e,room:t.roomSlug}),m=Ae(Be,{room:t.roomSlug,kind:e}),T.attachWs(m)}function Z(){return t.mode==="synced"}function Fe(e){if(e!=="solo"&&e!=="synced"||t.mode===e)return;const o=t.mode;t.mode=e,localStorage.setItem("oradio.mode",e),e==="synced"&&!t.syncedAudio&&t.player.stationId&&(p.stop(),k()),B();const n=o==="synced"&&!t.syncedAudio?"panel":"controller",s=e==="synced"&&!t.syncedAudio?"panel":"controller";n!==s&&O(),e==="synced"&&q(),l()}function De(e){if(e=!!e,t.syncedAudio===e)return;const o=t.mode==="synced"&&!t.syncedAudio?"panel":"controller";t.syncedAudio=e;try{localStorage.setItem("oradio.syncedAudio",e?"1":"0")}catch{}t.mode==="synced"&&(!e&&t.player.stationId&&(p.stop(),k()),B(),o!==(e?"controller":"panel")&&O(),e&&q()),l()}function q(){var n;const e=t.roomState;if(!e||!e.station_id||!e.playing||t.mode!=="synced")return;if(!t.syncedAudio){Q(),l();return}if(((n=p.station)==null?void 0:n.id)===e.station_id&&!p.audio.paused)return;const o=t.stations.find(s=>s.id===e.station_id)||t.favorites.find(s=>s.id===e.station_id)||(e.station&&e.station.id===e.station_id?e.station:null);o&&fe(o)}async function fe(e){if(t.player.loading&&t.player.stationId===e.id)return;const o=te();if(t.player.votes=null,k(),ue(e),t.player.stationId=e.id,t.player.stationName=e.name,t.player.genres=e.genres||[],t.player.loading=!0,l(),!await me(e.name,"Tap Start to join the group audio.")){t.player.loading=!1,l();return}await p.play(e),p.audio.paused&&p.audio.play().catch(()=>{}),ae(e.id);try{const s=await f.post(`/api/stations/${e.id}/play`,null,o?{signal:o.signal}:void 0);t.player.stationId===e.id&&(t.player.votes=s,s.sessionId&&(t.session={id:s.sessionId,stationId:e.id,startedAt:Date.now()}),N(e.id,s),l())}catch(s){E(s)}}function B(){const e=t.roomState;t.mode==="synced"&&t.syncedAudio?p.enableSync({clock:T,startedAt:(e==null?void 0:e.started_at)||null}):p.disableSync()}function Ee(e){!e||t.roomSlug===e||(t.roomSlug=e,localStorage.setItem("oradio.room",e),t.roomPeers=[],t.roomState=null,O(),l())}async function oe(){const[e,o,n,s]=await Promise.all([f.get(`/api/stations?sort=${encodeURIComponent(t.sort)}`),f.get("/api/me/favorites").catch(()=>[]),f.get("/api/me/history").catch(()=>[]),f.get("/api/v1/categories").catch(()=>[])]);t.stations=e,t.favorites=o,t.history=n,t.categories=s}async function qe(){t.stations=await f.get(`/api/stations?sort=${encodeURIComponent(t.sort)}`)}function Be(e){if(!(!e||!e.type)){if(e.type==="clock-pong"){T.handlePong(e);return}switch(e.type){case"hello":if(t.roomState=e.state||null,t.roomPeers=e.peers||[],z("hello",{mode:t.mode,syncedAudio:t.syncedAudio,roomState:t.roomState}),t.mode==="synced"&&Q(),B(),e.last_sync_pos&&t.mode==="synced"&&t.syncedAudio)try{p.acceptMasterPos(e.last_sync_pos)}catch{}t.mode==="synced"&&q(),l();return;case"sync-pos":if(t.mode==="synced"&&t.syncedAudio)try{p.acceptMasterPos(e)}catch{}return;case"presence":t.roomPeers=e.peers||[],l();return;case"devices":t.roomDevices={list:e.list||[],current:e.current||null},l();return;case"state":if(t.roomState={...t.roomState,...e},z("state",e),Le(),t.mode==="synced"&&Q(),t.mode==="synced"&&t.syncedAudio&&e.started_at&&(p.updateSyncTarget(e.started_at),B()),l(),t.mode==="synced"&&q(),t.mode==="synced"&&t.syncedAudio){const o=t.roomState;o&&o.playing===!1&&!p.audio.paused&&p.togglePause(),o&&o.playing&&o.station_id&&o.station_id===t.player.stationId&&p.audio.paused&&p.togglePause(),o&&!o.station_id&&t.player.stationId&&(p.stop(),t.player={...t.player,stationId:null,stationName:null,genres:[],playing:!1,loading:!1},l())}return;case"vote":{const o=e.stationId,n=e.stats||{};for(const s of[t.stations,t.favorites]){const i=s.find(c=>c.id===o);i&&("up"in n&&(i.up=n.up),"down"in n&&(i.down=n.down),"score"in n&&(i.score=n.score))}t.player.votes&&t.player.stationId===o&&(t.player.votes={...t.player.votes,...n}),l();return}case"plays":{const o=e.stationId;for(const n of[t.stations,t.favorites]){const s=n.find(i=>i.id===o);s&&(s.plays=e.plays)}t.player.votes&&t.player.stationId===o&&(t.player.votes={...t.player.votes,plays:e.plays}),l();return}case"peerVolume":{typeof e.value=="number"&&(p.setLocalVolume(Math.max(0,Math.min(1,e.value))),l());return}case"command":{if(e.action==="setSyncBuffer"&&Number.isFinite(e.value)){p.setSyncBufferMs(e.value);return}return}default:return}}}function Q(){var o,n,s,i,c,d,g;const e=t.roomState;if(e){if(t.mode==="synced"&&t.syncedAudio){t.player.stationId&&e.station_id===t.player.stationId?t.player={...t.player,stationName:((o=e.station)==null?void 0:o.name)||t.player.stationName,genres:((n=e.station)==null?void 0:n.genres)||t.player.genres||[]}:!t.player.stationId&&e.station_id&&(t.player={...t.player,stationId:e.station_id,stationName:((s=e.station)==null?void 0:s.name)||null,genres:((i=e.station)==null?void 0:i.genres)||[]});return}t.player={...t.player,stationId:e.station_id??((c=e.station)==null?void 0:c.id)??null,stationName:((d=e.station)==null?void 0:d.name)||null,genres:((g=e.station)==null?void 0:g.genres)||[],playing:!!e.playing,loading:!1,volume:typeof e.volume=="number"?e.volume:t.player.volume,error:null}}}function Ve(){ee(x);const e=a("div",{class:"login"},a("form",{onSubmit:async o=>{o.preventDefault();const n=new FormData(o.target);try{t.user=await f.post("/api/auth/login",{username:n.get("username"),password:n.get("password")}),await ye()}catch(s){o.target.querySelector(".err").textContent=s.message}}},a("h1",{},"Sign in"),a("input",{name:"username",placeholder:"Username",autocomplete:"username",required:!0}),a("input",{name:"password",type:"password",placeholder:"Password",autocomplete:"current-password",required:!0}),a("div",{class:"err"}),a("button",{type:"submit"},"Continue")));x.appendChild(e)}let w=0;function Oe(e,o,n,s){const i=e==="synced",c=e==="solo"||e==="synced"&&o,d=[];if(i){const g="Group (master) volume — broadcasts to the room";d.push(a("div",{class:"vol vol-master",title:g},a("span",{class:"vol-icon"},"📡"),a("input",{type:"range",min:0,max:1,step:.05,value:n,"aria-label":g,title:g,onInput:r=>{var b;const u=Number(r.target.value);t.roomState&&(t.roomState.volume=u),Pe(u);const v=(b=r.target.parentNode)==null?void 0:b.querySelector(".val");v&&(v.textContent=String(Math.round(u*100)))}}),a("span",{class:"val"},Math.round(n*100))))}if(c){const g=e==="synced"?"Local volume — this device only":"Local volume";d.push(a("div",{class:"vol vol-local",title:g},a("span",{class:"vol-icon"},s===0?"🔇":s<.5?"🔈":"🔊"),a("input",{type:"range",min:0,max:1,step:.05,value:s,"aria-label":g,title:g,onInput:r=>{var b;const u=Number(r.target.value);p.setLocalVolume(u);const v=(b=r.target.parentNode)==null?void 0:b.querySelector(".val");v&&(v.textContent=String(Math.round(u*100)))}}),a("span",{class:"val"},Math.round(s*100))))}return d}function l(){var ne,se;if(!t.user||D>0)return;const e=x.querySelector(".grid");e&&e.scrollTop>0&&(w=e.scrollTop),R(),ee(x);const o=t.player,n=new Set(t.favorites.map(h=>h.id)),s=o.votes,i=t.mode,c=t.syncedAudio,d=t.roomState,g=!!(d&&d.station_id&&d.playing);let r;i==="synced"&&d?r={stationId:d.station_id??null,stationName:((ne=d.station)==null?void 0:ne.name)??o.stationName,playing:!!d.playing}:r={stationId:o.stationId,stationName:o.stationName,playing:o.playing},t.pendingCommand&&(r={...r,playing:t.pendingCommand.playing,stationId:t.pendingCommand.stationId??r.stationId});const u=i==="synced"&&c&&g&&(!o.stationId||o.stationId!==d.station_id),v=u?"Join group audio":r.playing?i==="synced"?c?"Pause here & group":"Pause group":"Pause":i==="synced"?c?"Play here & group":"Play on group":"Play",b=i==="synced"?c?"Stop here & group":"Stop group":"Stop",y=g?((se=d.station)==null?void 0:se.name)||"group audio":"",L=u?`Tap ▶ to join “${y}`:i==="synced"?c?"Idle — pick a station to play here and on the group":"Idle — pick a station to play it on the group":"Idle";let S;o.loading?S="Connecting…":r.playing?i==="synced"&&c&&o.stationId===r.stationId&&!o.playing?S="On air — local audio loading…":i==="synced"&&!c?S="On air (silent observer)":S="On air":o.error?S=o.error:r.stationId?S="Paused":S=L;const ve=typeof(d==null?void 0:d.volume)=="number"?d.volume:.7,he=o.volume,be=a("section",{class:"now"},a("div",{class:"meta"},a("div",{class:"name"},r.stationName||"Select a station"),a("div",{class:"sub"},S),a("div",{class:"tags"},...(o.genres||[]).slice(0,4).map(h=>a("span",{class:"tag"},h)))),a("div",{class:"controls"},a("div",{class:"vote-group",title:"Vote on current station"},a("button",{class:`vote up ${(s==null?void 0:s.myVote)===1?"on":""}`,disabled:!r.stationId,title:"Upvote",onClick:()=>re(1)},a("span",{class:"vote-icon"},"▲"),a("span",{class:"vote-count"},String((s==null?void 0:s.up)??0))),a("button",{class:`vote down ${(s==null?void 0:s.myVote)===-1?"on":""}`,disabled:!r.stationId,title:"Downvote",onClick:()=>re(-1)},a("span",{class:"vote-icon"},"▼"),a("span",{class:"vote-count"},String((s==null?void 0:s.down)??0)))),a("button",{class:`btn-play ${o.loading?"loading":""} ${u?"cta-join":""}`,title:v,"aria-label":v,onClick:()=>{var h;if(Y(),t.mode==="synced"&&!t.syncedAudio){const C=r.stationId||((h=t.favorites[0])==null?void 0:h.id);_({stationId:C,playing:!r.playing}),m==null||m.send({type:"command",action:r.playing?"pause":"play",stationId:C}),l();return}if(u){const C=t.stations.find(G=>G.id===d.station_id)||t.favorites.find(G=>G.id===d.station_id);if(C){_({stationId:C.id,playing:!0}),fe(C);return}}if(r.stationId){const C=r.playing;t.mode==="synced"&&t.syncedAudio&&_({stationId:r.stationId,playing:!C}),p.togglePause(),Z()&&(m==null||m.send({type:"command",action:C?"pause":"play",stationId:r.stationId}))}else t.favorites[0]&&V(t.favorites[0])}},r.playing?"❚❚":"▶"),a("button",{class:"btn-stop",title:b,"aria-label":b,disabled:!r.stationId,onClick:()=>{Y(),t.mode==="synced"&&!t.syncedAudio?(_({stationId:null,playing:!1}),m==null||m.send({type:"command",action:"stop"}),l()):(t.mode==="synced"&&t.syncedAudio&&_({stationId:null,playing:!1}),p.stop(),k(),Z()&&(m==null||m.send({type:"command",action:"stop"})))}},"■"),...Oe(i,c,ve,he))),Se=t.user.role==="admin",Ie=a("div",{class:"header"},a("div",{class:"tabs"},...["favorites","browse","recent"].map(h=>a("button",{class:`tab ${t.tab===h?"active":""}`,onClick:()=>{t.tab=h,w=0,l()}},h==="favorites"?"★ Favorites":h==="browse"?"🌐 Browse":"⏱ Recent"))),a("div",{class:"header-tools"},Ue(),t.tab==="browse"?a("select",{class:"sort",title:"Sort browse list",onChange:h=>{t.sort=h.target.value,w=0,qe().then(l)}},a("option",{value:"hot",selected:t.sort==="hot"},"🔥 Hot (smart)"),a("option",{value:"top",selected:t.sort==="top"},"▲ Top voted"),a("option",{value:"plays",selected:t.sort==="plays"},"▶ Most played"),a("option",{value:"controversial",selected:t.sort==="controversial"},"⚡ Controversial"),a("option",{value:"name",selected:t.sort==="name"},"A → Z")):null,a("input",{class:"search",type:"search",placeholder:"Search…",value:t.query,onInput:h=>{t.query=h.target.value,He()}}),a("button",{class:"btn-random",title:`Play random station (mode: ${t.randomMode}). Right-click to switch mode.`,onClick:Ke,onContextMenu:h=>{h.preventDefault(),Je()}},a("span",{class:"rand-icon"},"🎲"),a("span",{class:"rand-mode"},t.randomMode==="favorites"?"★":"All")),a("a",{class:"btn-docs",href:"/docs/",target:"_blank",rel:"noopener",title:"Open API reference"},"API"),Se?a("button",{class:"btn-add",title:"Add station",onClick:Xe},"+"):null)),U=a("section",{class:"lib"},Ie);t.tab==="browse"&&t.categories.length&&U.appendChild(Ge());const M=a("div",{class:"grid"});M.id="grid",M.addEventListener("scroll",()=>{w=M.scrollTop},{passive:!0}),U.appendChild(M),x.appendChild(be),x.appendChild(U),ge(M,n),w&&(M.scrollTop=w,requestAnimationFrame(()=>{w&&(M.scrollTop=w)}))}function Ue(){var u,v,b;const e=t.roomPeers||[],o=e.some(y=>y.kind==="display"),n=((u=t.rooms.find(y=>y.slug===t.roomSlug))==null?void 0:u.name)||"My group",s=[{id:"solo",icon:"🎧",label:"Solo",title:"Local audio only. Controls do not affect the group."},{id:"synced",icon:"🔗",label:"Synced",title:`Mirror the ${n} group's UI. Controls target the group. Local audio is optional.`}],i=t.sync||{status:"off",error:0,rtt:null,bufferMs:8e3,delay:0},c=t.mode==="synced"&&t.syncedAudio&&i.status!=="off",d=((i.bufferMs??8e3)/1e3).toFixed(1),g=(i.delay??0).toFixed(2),r={"no-anchor":{dot:"⚪",label:"wait",title:"Waiting for the room to start playback"},measuring:{dot:"🟡",label:"…",title:`Measuring clock (buffer ${d}s)`},"in-sync":{dot:"🟢",label:"sync",title:`In sync — holding ${g}s of ${d}s buffer (RTT ${((b=(v=i.rtt)==null?void 0:v.toFixed)==null?void 0:b.call(v,0))??"?"} ms)`},lagging:{dot:"🟡",label:"lag",title:`Joined too late by ${i.error.toFixed(1)}s — increase the buffer to catch up`},"no-buffer":{dot:"⚠️",label:"cors",title:"Stream is cross-origin without CORS — sync buffer disabled."},off:{dot:"",label:"",title:""}}[i.status]||{dot:"",label:"",title:""};return a("div",{class:`room-pill mode-${t.mode}`,title:"Listening mode & group"},a("div",{class:"rp-group",title:o?"Group display online":"Group"},a("span",{class:"rp-group-icon"},o?"📻":"🏠"),a("select",{class:"rp-group-select",onChange:y=>Ee(y.target.value),"aria-label":"Group"},...(t.rooms.length?t.rooms:[{slug:t.roomSlug||"",name:"My group"}]).map(y=>a("option",{value:y.slug,selected:y.slug===t.roomSlug},y.name))),a("span",{class:"rp-peers",title:`${e.length} client(s) connected${o?" • display online":""}`},`${e.length}`)),a("div",{class:"mode-pill",role:"radiogroup","aria-label":"Listening mode"},a("span",{class:`mode-indicator pos-${t.mode}`,"aria-hidden":"true"}),...s.map(y=>a("button",{class:`mode-seg ${t.mode===y.id?"on":""}`,role:"radio","aria-checked":t.mode===y.id?"true":"false",title:y.title,onClick:()=>Fe(y.id)},a("span",{class:"mode-seg-icon"},y.icon),a("span",{class:"mode-seg-label"},y.label)))),t.mode==="synced"?a("label",{class:`synced-audio-toggle ${t.syncedAudio?"on":"off"}`,title:t.syncedAudio?"Local synced audio ON — this device plays the group stream (aligned to the room).":"Local synced audio OFF — silent observer of the group."},a("input",{type:"checkbox",checked:t.syncedAudio,onChange:y=>De(y.target.checked),"aria-label":"Local synced audio"}),a("span",{class:"sa-icon"},t.syncedAudio?"🔊":"🔇"),a("span",{class:"sa-label"},t.syncedAudio?"Audio":"Silent")):null,c?a("span",{class:`sync-chip sync-${i.status}`,title:r.title},a("span",{class:"sync-dot"},r.dot),a("span",{class:"sync-label"},r.label),a("input",{class:"sync-buffer",type:"range",min:"2",max:"20",step:"0.5",value:String((i.bufferMs??8e3)/1e3),title:`Sync buffer: ${d}s. Bigger = easier for slow devices to align (more startup lag).`,"aria-label":"Sync buffer seconds",onInput:y=>{const L=Number(y.target.value);if(!Number.isFinite(L))return;const S=L*1e3;p.setSyncBufferMs(S),t.mode==="synced"&&Te(S),y.target.title=`Sync buffer: ${L.toFixed(1)}s. Bigger = easier for slow devices to align (more startup lag).`}})):null)}function Ge(){return a("div",{class:"chips"},a("button",{class:`chip ${t.selectedCategory?"":"active"}`,onClick:()=>{t.selectedCategory=null,w=0,l()}},`All (${t.stations.length})`),...t.categories.filter(e=>e.count>0).map(e=>a("button",{class:`chip ${t.selectedCategory===e.id?"active":""}`,onClick:()=>{t.selectedCategory=e.id,w=0,l()}},`${e.icon||""} ${e.label} (${e.count})`.trim())))}function We(){let e=[];if(t.tab==="favorites")e=t.favorites;else if(t.tab==="browse")e=t.stations,t.selectedCategory&&(e=e.filter(n=>n.category===t.selectedCategory));else if(t.tab==="recent"){const n=new Set;e=t.history.filter(s=>!n.has(s.station_id)&&n.add(s.station_id)).map(s=>t.stations.find(i=>i.id===s.station_id)).filter(Boolean)}const o=t.query.trim().toLowerCase();return o&&(e=e.filter(n=>n.name.toLowerCase().includes(o)||(n.country||"").toLowerCase().includes(o)||(n.genres||[]).some(s=>s.toLowerCase().includes(o)))),e}function He(){const e=document.getElementById("grid");if(!e)return;const o=new Set(t.favorites.map(n=>n.id));ge(e,o)}function ge(e,o){ee(e);const n=We();if(!n.length){e.appendChild(a("div",{class:"empty"},t.tab==="favorites"?"No favorites yet — long-press or tap ★ on a station.":t.query?"No matches.":"Nothing here yet."));return}const s=t.mode==="synced"&&t.roomState?t.roomState.station_id??null:t.player.stationId;for(const i of n){const c=typeof i.score=="number"?i.score:0,d=(i.up??0)-(i.down??0),g=d>0?"pos":d<0?"neg":"neu",r=a("div",{class:`card ${s===i.id?"playing":""}`,role:"button",tabindex:0,onClick:()=>V(i),onContextMenu:u=>{u.preventDefault(),le(u.clientX,u.clientY,i)}},a("div",{class:"art"},i.image_display_url||i.image_url?a("img",{class:"art-img",src:i.image_display_url||i.image_url,alt:"",loading:"lazy",referrerpolicy:"no-referrer",onError:u=>{const v=u.target.parentNode;u.target.remove(),v&&v.appendChild(a("span",{class:"art-glyph"},"♪"))}}):a("span",{class:"art-glyph"},"♪")),a("div",{class:"card-body"},a("div",{class:"n"},i.name),a("div",{class:"g"},(i.genres||[]).slice(0,3).join(" · ")||i.country||"—")),a("div",{class:`score-badge ${g}`,title:`${i.up??0} · ▼${i.down??0} · ▶${i.plays??0} · score ${c.toFixed(2)}`},d>0?`+${d}`:String(d)),a("button",{class:`fav ${o.has(i.id)?"on":""}`,title:o.has(i.id)?"Remove favorite":"Add favorite",onClick:u=>{u.stopPropagation(),je(i)}},o.has(i.id)?"★":"☆"),a("button",{class:"more",title:"API endpoints",onClick:u=>{u.stopPropagation();const v=u.currentTarget.getBoundingClientRect();le(v.right,v.bottom,i)}},"⋯"));e.appendChild(r)}}async function je(e){t.favorites.some(n=>n.id===e.id)?await f.del(`/api/me/favorites/${e.id}`):await f.put(`/api/me/favorites/${e.id}`,{position:t.favorites.length}),t.favorites=await f.get("/api/me/favorites"),l()}function Je(){t.randomMode=t.randomMode==="favorites"?"all":"favorites",localStorage.setItem("oradio.randomMode",t.randomMode),A(`Random mode: ${t.randomMode==="favorites"?"favorites only":"all stations"}`),l()}async function Ke(){try{const e=t.randomMode==="favorites"?"/api/me/favorites/random":"/api/v1/stations/random",o=await f.get(e);let n=o;if(n.id==null&&(n=t.stations.find(s=>s.uuid===o.uuid)||null),!n){A("Random station not in cache");return}V(n)}catch(e){const o=t.randomMode==="favorites"?t.favorites:t.stations;if(!o.length){A(e.message||"No stations available");return}V(o[Math.floor(Math.random()*o.length)])}}function ae(e){t.history.unshift({station_id:e,started_at:new Date().toISOString()})}async function V(e){Y();const o=te();if(t.player.votes=null,k(),t.mode==="solo"&&ue(e),t.mode==="synced"&&!t.syncedAudio){_({stationId:e.id,playing:!0}),m==null||m.send({type:"command",action:"play",stationId:e.id}),t.player={...t.player,stationId:e.id,stationName:e.name,genres:e.genres||[],playing:!0,loading:!1,error:null},l();try{const n=await f.get(`/api/stations/${e.id}/votes`,o?{signal:o.signal}:void 0);t.player.stationId===e.id&&(t.player.votes=n,N(e.id,n),l())}catch(n){E(n)}return}p.play(e),ae(e.id),Z()&&(m==null||m.send({type:"command",action:"play",stationId:e.id}));try{const n=await f.post(`/api/stations/${e.id}/play`,null,o?{signal:o.signal}:void 0);t.player.stationId===e.id?(t.player.votes=n,n.sessionId&&(t.session={id:n.sessionId,stationId:e.id,startedAt:Date.now()}),N(e.id,n),l()):n.sessionId&&f.post(`/api/stations/${e.id}/play/end`,{sessionId:n.sessionId,duration_ms:0}).catch(()=>{})}catch(n){if(E(n))return;try{const s=await f.get(`/api/stations/${e.id}/votes`,o?{signal:o.signal}:void 0);t.player.stationId===e.id&&(t.player.votes=s,N(e.id,s),l())}catch{}}}function k({beacon:e=!1}={}){const o=t.session;if(!o||!o.id)return;t.session=null;const n={sessionId:o.id,duration_ms:Math.max(0,Date.now()-o.startedAt)},s=`/api/stations/${o.stationId}/play/end`;if(e&&typeof navigator<"u"&&navigator.sendBeacon)try{navigator.sendBeacon(s,new Blob([JSON.stringify(n)],{type:"application/json"}));return}catch{}f.post(s,n).catch(()=>{})}typeof window<"u"&&(window.addEventListener("pagehide",()=>k({beacon:!0})),window.addEventListener("beforeunload",()=>k({beacon:!0})));async function re(e){var i;const o=t.player.stationId;if(!o)return;const s=(((i=t.player.votes)==null?void 0:i.myVote)||0)===e?0:e;try{const c=await f.post(`/api/stations/${o}/vote`,{value:s});t.player.votes=c,N(o,c),l()}catch(c){A(c.message||"Vote failed")}}function N(e,o){const n=[t.stations,t.favorites];for(const s of n){const i=s.find(c=>c.id===e);i&&(i.up=o.up,i.down=o.down,i.plays=o.plays,i.score=o.score,i.my_vote=o.myVote)}}let I=null;function R(){I&&(I.remove(),I=null)}function ze(e){const o=location.origin,n=`${o}/api/v1`,s=[];return e.id!=null&&s.push({label:"Station (original)",url:`${o}/api/stations/${e.id}`}),e.uuid&&s.push({label:"Station detail",url:`${n}/stations/${e.uuid}`},{label:"Stream redirect",url:`${n}/stations/${e.uuid}/stream`},{label:"MP3 stream",url:`${n}/stations/${e.uuid}/stream?format=mp3`},{label:"AAC stream",url:`${n}/stations/${e.uuid}/stream?format=aac`},{label:"HLS stream",url:`${n}/stations/${e.uuid}/stream?format=hls`}),s.push({label:"All stations",url:`${n}/stations`},{label:"Health",url:`${n}/health`}),s}function le(e,o,n){R();const s=ze(n);I=a("div",{class:"ctx-menu",role:"menu"},a("div",{class:"ctx-title"},n.name),a("div",{class:"ctx-sub"},n.uuid?`uuid · ${n.uuid}`:n.id!=null?`id · ${n.id} (no uuid — public v1 hidden)`:"no identifier"),...s.length?s.map(r=>a("div",{class:"ctx-row"},a("div",{class:"ctx-row-text"},a("div",{class:"ctx-label"},r.label),a("div",{class:"ctx-url"},r.url)),a("button",{class:"ctx-btn",title:"Copy",onClick:async u=>{u.stopPropagation();try{await navigator.clipboard.writeText(r.url),A("Copied")}catch{A("Copy failed")}}},"⧉"),a("button",{class:"ctx-btn",title:"Open",onClick:u=>{u.stopPropagation(),window.open(r.url,"_blank","noopener")}},"↗"))):[a("div",{class:"ctx-empty"},"No public API for this station yet (missing uuid).")],t.user.role==="admin"?a("button",{class:"ctx-danger",onClick:async()=>{if(R(),!!confirm(`Delete ${n.name}?`))try{await f.del(`/api/stations/${n.id}`),await oe(),l(),A("Deleted")}catch(r){A(r.message||"Delete failed")}}},"🗑 Delete"):null),document.body.appendChild(I);const i=I.offsetWidth,c=I.offsetHeight,d=Math.min(e,window.innerWidth-i-8),g=Math.min(o,window.innerHeight-c-8);I.style.left=`${Math.max(8,d)}px`,I.style.top=`${Math.max(8,g)}px`}document.addEventListener("click",e=>{I&&!I.contains(e.target)&&R()});document.addEventListener("keydown",e=>{e.key==="Escape"&&R()});async function Xe(){const e=document.createElement("dialog");e.className="add-station";const o={name:"",country:"",genres:"",image_url:"",homepage:"",streamUrl:"",streamFormat:"mp3"},n=a("div",{class:"err"});e.appendChild(a("form",{method:"dialog",onSubmit:async s=>{s.preventDefault(),n.textContent="";const i={name:o.name.trim(),country:o.country.trim()||null,homepage:o.homepage.trim()||null,image_url:o.image_url.trim()||null,genres:o.genres.split(",").map(c=>c.trim()).filter(Boolean),streams:o.streamUrl.trim()?[{url:o.streamUrl.trim(),format:o.streamFormat,priority:0}]:[]};if(!i.name){n.textContent="Name is required.";return}try{await f.post("/api/stations",i),e.close(),await oe(),l(),A("Station added")}catch(c){n.textContent=c.message||"Failed to add station"}}},a("h2",{},"Add station"),a("label",{},"Name",a("input",{required:!0,autofocus:!0,onInput:s=>o.name=s.target.value})),a("div",{class:"row2"},a("label",{},"Country",a("input",{maxlength:4,placeholder:"NL",onInput:s=>o.country=s.target.value})),a("label",{},"Genres",a("input",{placeholder:"jazz, electronic",onInput:s=>o.genres=s.target.value}))),a("label",{},"Homepage",a("input",{type:"url",placeholder:"https://…",onInput:s=>o.homepage=s.target.value})),a("label",{},"Image URL",a("input",{type:"url",placeholder:"https://…/logo.png",onInput:s=>o.image_url=s.target.value})),a("div",{class:"row2"},a("label",{},"Stream URL",a("input",{type:"url",placeholder:"https://…/stream",onInput:s=>o.streamUrl=s.target.value})),a("label",{},"Format",a("select",{onChange:s=>o.streamFormat=s.target.value},...["mp3","aac","ogg","hls","m3u","pls","unknown"].map(s=>a("option",{value:s,selected:s==="mp3"},s))))),n,a("div",{class:"actions"},a("button",{class:"btn-ghost",type:"button",onClick:()=>e.close()},"Cancel"),a("button",{class:"btn-primary",type:"submit"},"Add")))),document.body.appendChild(e),e.showModal(),e.addEventListener("close",()=>e.remove())}let de=null;function A(e){const o=document.querySelector(".toast");o&&o.remove();const n=a("div",{class:"toast"},e);document.body.appendChild(n),clearTimeout(de),de=setTimeout(()=>n.remove(),2200)}async function Ye(){var e;try{await((e=navigator.wakeLock)==null?void 0:e.request("screen"))}catch{}document.addEventListener("visibilitychange",()=>{var o;document.visibilityState==="visible"&&((o=navigator.wakeLock)==null||o.request("screen").catch(()=>{}))})}document.addEventListener("contextmenu",e=>{window.matchMedia("(display-mode: fullscreen)").matches&&e.preventDefault()});ye();