Files
radio-explorer/server/public/assets/kiosk-CzWLja7k.js
Marco Mooren b86dcfbb8d Add master display UI with audio output management and styling
- Implement main.js for the master display functionality, including WebSocket connection, audio output management, and state handling.
- Create style.css for the master display's visual design, ensuring a cohesive look and feel with a dark theme and responsive layout.
- Integrate device management with a fallback for non-Electron environments, allowing users to select audio outputs.
- Add features for managing favorites, including toggling favorites and filtering by genre.
- Enhance user experience with a responsive favorites grid and drag-to-scroll functionality.
2026-05-11 17:55:09 +02:00

2 lines
18 KiB
JavaScript

import"./modulepreload-polyfill-B5Qt9EMX.js";import{P as N,a as c,c as x,e as a}from"./player-BBOsFRH-.js";import{c as R}from"./ws-BM1PmMVd.js";const w=document.getElementById("app"),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:localStorage.getItem("oradio.mode")==="follow-room"?"follow-room":"play-here",roomState:null,roomPeers:[],roomDevices:{list:[],current:null},player:{stationId:null,stationName:null,genres:[],playing:!1,loading:!1,volume:.7,votes:null},session:null},h=new N({onState:e=>{t.player={...t.player,...e},l()}});let p;async function q(){try{t.user=await c.get("/api/auth/me")}catch{B();return}await M();try{t.rooms=await c.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}`}k(),l(),Z()}function k(){if(p)try{p.close()}catch{}const e=t.mode==="follow-room"?"panel":"controller";p=R(U,{room:t.roomSlug,kind:e})}function E(e){e!=="play-here"&&e!=="follow-room"||t.mode!==e&&(t.mode=e,localStorage.setItem("oradio.mode",e),e==="follow-room"&&t.player.stationId&&(h.stop(),I()),k(),l())}function T(e){!e||t.roomSlug===e||(t.roomSlug=e,localStorage.setItem("oradio.room",e),t.roomPeers=[],t.roomState=null,k(),l())}async function M(){const[e,o,n,s]=await Promise.all([c.get(`/api/stations?sort=${encodeURIComponent(t.sort)}`),c.get("/api/me/favorites").catch(()=>[]),c.get("/api/me/history").catch(()=>[]),c.get("/api/v1/categories").catch(()=>[])]);t.stations=e,t.favorites=o,t.history=n,t.categories=s}async function F(){t.stations=await c.get(`/api/stations?sort=${encodeURIComponent(t.sort)}`)}function U(e){if(!(!e||!e.type))switch(e.type){case"hello":t.roomState=e.state||null,t.roomPeers=e.peers||[],t.mode==="follow-room"&&P(),l();return;case"presence":t.roomPeers=e.peers||[],l();return;case"devices":t.roomDevices={list:e.list||[],current:e.current||null},l();return;case"state":t.roomState={...t.roomState,...e},t.mode==="follow-room"&&P(),l();return;case"vote":{const o=e.stationId,n=e.stats||{};for(const s of[t.stations,t.favorites]){const i=s.find(d=>d.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"command":{if(t.mode!=="play-here")return;if(e.action==="play"&&e.stationId){const o=t.stations.find(n=>n.id===e.stationId);o&&C(o)}else e.action==="pause"?h.togglePause():e.action==="volume"?h.setVolume(e.value):e.action==="stop"&&h.stop();return}default:return}}function P(){var o,n,s;const e=t.roomState;e&&(t.player={...t.player,stationId:e.station_id??((o=e.station)==null?void 0:o.id)??null,stationName:((n=e.station)==null?void 0:n.name)||null,genres:((s=e.station)==null?void 0:s.genres)||[],playing:!!e.playing,loading:!1,volume:typeof e.volume=="number"?e.volume:t.player.volume,error:null})}function B(){x(w);const e=a("div",{class:"login"},a("form",{onSubmit:async o=>{o.preventDefault();const n=new FormData(o.target);try{t.user=await c.post("/api/auth/login",{username:n.get("username"),password:n.get("password")}),await q()}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")));w.appendChild(e)}let f=0;function l(){if(!t.user)return;const e=w.querySelector(".grid");e&&e.scrollTop>0&&(f=e.scrollTop),S(),x(w);const o=t.player,n=new Set(t.favorites.map(r=>r.id)),s=o.votes,i=a("section",{class:"now"},a("div",{class:"meta"},a("div",{class:"name"},o.stationName||"Select a station"),a("div",{class:"sub"},o.loading?"Connecting…":o.playing?"On air":o.error?o.error:o.stationId?"Paused":"Idle"),a("div",{class:"tags"},...(o.genres||[]).slice(0,4).map(r=>a("span",{class:"tag"},r)))),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:!o.stationId,title:"Upvote",onClick:()=>L(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:!o.stationId,title:"Downvote",onClick:()=>L(-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":""}`,title:o.playing?"Pause":"Play",onClick:()=>{var r;t.mode==="follow-room"?p==null||p.send({type:"command",action:o.playing?"pause":(o.stationId,"play"),stationId:o.stationId||((r=t.favorites[0])==null?void 0:r.id)}):o.stationId?h.togglePause():t.favorites[0]&&C(t.favorites[0])}},o.playing?"❚❚":"▶"),a("button",{class:"btn-stop",title:"Stop",disabled:!o.stationId,onClick:()=>{t.mode==="follow-room"?p==null||p.send({type:"command",action:"stop"}):(h.stop(),I())}},"■"),a("div",{class:"vol"},a("span",{class:"vol-icon"},o.volume===0?"🔇":o.volume<.5?"🔈":"🔊"),a("input",{type:"range",min:0,max:1,step:.05,value:o.volume,"aria-label":"Volume",onInput:r=>{const g=Number(r.target.value);t.mode==="follow-room"?(t.player.volume=g,p==null||p.send({type:"command",action:"volume",value:g})):h.setVolume(g)}}),a("span",{class:"val"},Math.round(o.volume*100))))),d=t.user.role==="admin",y=a("div",{class:"header"},a("div",{class:"tabs"},...["favorites","browse","recent"].map(r=>a("button",{class:`tab ${t.tab===r?"active":""}`,onClick:()=>{t.tab=r,f=0,l()}},r==="favorites"?"★ Favorites":r==="browse"?"🌐 Browse":"⏱ Recent"))),a("div",{class:"header-tools"},V(),t.tab==="browse"?a("select",{class:"sort",title:"Sort browse list",onChange:r=>{t.sort=r.target.value,f=0,F().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:r=>{t.query=r.target.value,G()}}),a("button",{class:"btn-random",title:`Play random station (mode: ${t.randomMode}). Right-click to switch mode.`,onClick:z,onContextMenu:r=>{r.preventDefault(),j()}},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"),d?a("button",{class:"btn-add",title:"Add station",onClick:Y},"+"):null)),b=a("section",{class:"lib"},y);t.tab==="browse"&&t.categories.length&&b.appendChild(H());const u=a("div",{class:"grid"});u.id="grid",u.addEventListener("scroll",()=>{f=u.scrollTop},{passive:!0}),b.appendChild(u),w.appendChild(i),w.appendChild(b),D(u,n),f&&(u.scrollTop=f,requestAnimationFrame(()=>{f&&(u.scrollTop=f)}))}function V(){const e=t.roomPeers||[],o=e.some(n=>n.kind==="display");return a("div",{class:"room-pill",title:"Listening room"},a("span",{class:"room-icon"},"🏠"),a("select",{class:"room-select",onChange:n=>T(n.target.value),"aria-label":"Room"},...(t.rooms.length?t.rooms:[{slug:t.roomSlug||"",name:"My room"}]).map(n=>a("option",{value:n.slug,selected:n.slug===t.roomSlug},n.name))),a("span",{class:"room-peers",title:`${e.length} client(s)${o?" • display online":""}`},`${e.length}${o?"◉":""}`),a("button",{class:`room-mode ${t.mode}`,title:t.mode==="follow-room"?"Mirroring the room display. Click to play audio in this browser.":"Playing audio in this browser. Click to follow the room display.",onClick:()=>E(t.mode==="follow-room"?"play-here":"follow-room")},t.mode==="follow-room"?"Follow":"Here"))}function H(){return a("div",{class:"chips"},a("button",{class:`chip ${t.selectedCategory?"":"active"}`,onClick:()=>{t.selectedCategory=null,f=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,f=0,l()}},`${e.icon||""} ${e.label} (${e.count})`.trim())))}function W(){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 G(){const e=document.getElementById("grid");if(!e)return;const o=new Set(t.favorites.map(n=>n.id));D(e,o)}function D(e,o){x(e);const n=W();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.player;for(const i of n){const d=typeof i.score=="number"?i.score:0,y=(i.up??0)-(i.down??0),b=y>0?"pos":y<0?"neg":"neu",u=a("div",{class:`card ${s.stationId===i.id?"playing":""}`,role:"button",tabindex:0,onClick:()=>C(i),onContextMenu:r=>{r.preventDefault(),_(r.clientX,r.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:r=>{const g=r.target.parentNode;r.target.remove(),g&&g.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 ${b}`,title:`${i.up??0} · ▼${i.down??0} · ▶${i.plays??0} · score ${d.toFixed(2)}`},y>0?`+${y}`:String(y)),a("button",{class:`fav ${o.has(i.id)?"on":""}`,title:o.has(i.id)?"Remove favorite":"Add favorite",onClick:r=>{r.stopPropagation(),O(i)}},o.has(i.id)?"★":"☆"),a("button",{class:"more",title:"API endpoints",onClick:r=>{r.stopPropagation();const g=r.currentTarget.getBoundingClientRect();_(g.right,g.bottom,i)}},"⋯"));e.appendChild(u)}}async function O(e){t.favorites.some(n=>n.id===e.id)?await c.del(`/api/me/favorites/${e.id}`):await c.put(`/api/me/favorites/${e.id}`,{position:t.favorites.length}),t.favorites=await c.get("/api/me/favorites"),l()}function j(){t.randomMode=t.randomMode==="favorites"?"all":"favorites",localStorage.setItem("oradio.randomMode",t.randomMode),v(`Random mode: ${t.randomMode==="favorites"?"favorites only":"all stations"}`),l()}async function z(){try{const e=t.randomMode==="favorites"?"/api/me/favorites/random":"/api/v1/stations/random",o=await c.get(e);let n=o;if(n.id==null&&(n=t.stations.find(s=>s.uuid===o.uuid)||null),!n){v("Random station not in cache");return}C(n)}catch(e){const o=t.randomMode==="favorites"?t.favorites:t.stations;if(!o.length){v(e.message||"No stations available");return}C(o[Math.floor(Math.random()*o.length)])}}function J(e){t.history.unshift({station_id:e,started_at:new Date().toISOString()})}async function C(e){if(t.player.votes=null,I(),t.mode==="follow-room"){p==null||p.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 o=await c.get(`/api/stations/${e.id}/votes`);t.player.stationId===e.id&&(t.player.votes=o,$(e.id,o),l())}catch{}return}h.play(e),J(e.id);try{const o=await c.post(`/api/stations/${e.id}/play`);t.player.stationId===e.id?(t.player.votes=o,o.sessionId&&(t.session={id:o.sessionId,stationId:e.id,startedAt:Date.now()}),$(e.id,o),l()):o.sessionId&&c.post(`/api/stations/${e.id}/play/end`,{sessionId:o.sessionId,duration_ms:0}).catch(()=>{})}catch{try{const n=await c.get(`/api/stations/${e.id}/votes`);t.player.stationId===e.id&&(t.player.votes=n,$(e.id,n),l())}catch{}}}function I({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{}c.post(s,n).catch(()=>{})}typeof window<"u"&&(window.addEventListener("pagehide",()=>I({beacon:!0})),window.addEventListener("beforeunload",()=>I({beacon:!0})));async function L(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 d=await c.post(`/api/stations/${o}/vote`,{value:s});t.player.votes=d,$(o,d),l()}catch(d){v(d.message||"Vote failed")}}function $(e,o){const n=[t.stations,t.favorites];for(const s of n){const i=s.find(d=>d.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 m=null;function S(){m&&(m.remove(),m=null)}function X(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 _(e,o,n){S();const s=X(n);m=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(u=>a("div",{class:"ctx-row"},a("div",{class:"ctx-row-text"},a("div",{class:"ctx-label"},u.label),a("div",{class:"ctx-url"},u.url)),a("button",{class:"ctx-btn",title:"Copy",onClick:async r=>{r.stopPropagation();try{await navigator.clipboard.writeText(u.url),v("Copied")}catch{v("Copy failed")}}},"⧉"),a("button",{class:"ctx-btn",title:"Open",onClick:r=>{r.stopPropagation(),window.open(u.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(S(),!!confirm(`Delete ${n.name}?`))try{await c.del(`/api/stations/${n.id}`),await M(),l(),v("Deleted")}catch(u){v(u.message||"Delete failed")}}},"🗑 Delete"):null),document.body.appendChild(m);const i=m.offsetWidth,d=m.offsetHeight,y=Math.min(e,window.innerWidth-i-8),b=Math.min(o,window.innerHeight-d-8);m.style.left=`${Math.max(8,y)}px`,m.style.top=`${Math.max(8,b)}px`}document.addEventListener("click",e=>{m&&!m.contains(e.target)&&S()});document.addEventListener("keydown",e=>{e.key==="Escape"&&S()});async function Y(){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(d=>d.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 c.post("/api/stations",i),e.close(),await M(),l(),v("Station added")}catch(d){n.textContent=d.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 A=null;function v(e){const o=document.querySelector(".toast");o&&o.remove();const n=a("div",{class:"toast"},e);document.body.appendChild(n),clearTimeout(A),A=setTimeout(()=>n.remove(),2200)}async function Z(){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()});q();