Add web mixer with native input volume and output selection
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
let state, token, pending=false, tab='dac', refreshTimer;
|
||||
const $=id=>document.getElementById(id);
|
||||
function notice(message,error=false){$('notice').textContent=message;$('notice').classList.toggle('error',error)}
|
||||
function node(tag,text,cls){const el=document.createElement(tag);if(text!==undefined)el.textContent=text;if(cls)el.className=cls;return el}
|
||||
function selectOptions(el,items,value){const old=el.value;el.replaceChildren();for(const item of items){const op=node('option',item.name);op.value=item.value;el.append(op)}el.value=items.some(x=>x.value===old)?old:value;if(!el.value&&items.length)el.value=items[0].value}
|
||||
function showTab(next){tab=next;$('dacPanel').hidden=tab!=='dac';$('btPanel').hidden=tab!=='bluetooth';$('tabDac').classList.toggle('selected',tab==='dac');$('tabBt').classList.toggle('selected',tab==='bluetooth')}
|
||||
function renderInputs(items){const active=document.activeElement;if(active&&active.type==='range'&&!pending)return;$('inputs').replaceChildren();for(const item of items){const card=node('div',undefined,'input'),top=node('div',undefined,'inputTop'),title=node('div');title.append(node('h3',item.name),node('span',item.kind==='airplay'?'AIRPLAY 2':'BLUETOOTH','kind'));top.append(title,node('span',!item.available?'제어 준비 중':item.playing?'재생 중':'대기 중','playState'));const value=node('div',String(item.volume),'volumeValue');value.append(node('small','%'));const range=node('input');range.type='range';range.min=0;range.max=100;range.step=1;range.value=item.volume;range.disabled=!item.available||pending;range.setAttribute('aria-label',item.name+' 입력 볼륨');range.oninput=()=>{value.firstChild.textContent=range.value};range.onchange=()=>action({action:'volume',input:item.id,volume:Number(range.value)},'입력 볼륨을 변경했습니다.');const marks=node('div',undefined,'rangeLabels');marks.append(node('span','0'),node('span','100'));const mute=node('button',item.muted?'음소거 해제':'음소거',item.muted?'mute on':'mute');mute.disabled=!item.available||pending;mute.onclick=()=>action({action:'mute',input:item.id,mute:!item.muted},item.muted?'음소거를 해제했습니다.':'입력을 음소거했습니다.');card.append(top,value,range,marks,mute);$('inputs').append(card)}}
|
||||
async function refresh(initial=false){try{const r=await fetch('/api/state');const data=await r.json();if(!r.ok)throw Error(data.error);state=data;token=data.csrf;$('deviceName').textContent=data.config.name;renderInputs(data.inputs);selectOptions($('dacSelect'),data.dacs.map(d=>({name:d.name,value:d.pcm})),data.config.dac_pcm);selectOptions($('btSelect'),data.bluetooth.filter(d=>d.output&&(d.paired||d.address===data.config.bluetooth_device)).map(d=>({name:d.name+(!d.bonded?' · 등록 필요':d.connected?' · 연결됨':''),value:d.address})),data.config.bluetooth_device);$('outputState').textContent=data.config.output==='dac'?'현재 DAC 출력':'현재 Bluetooth 출력';if(initial)showTab(data.config.output);$('useDac').disabled=pending||!data.dacs.length;$('useBt').disabled=pending||!$('btSelect').options.length;$('scan').disabled=pending;if(initial)notice('믹서에 연결됐습니다. 입력별 볼륨을 조절해 보세요.')}catch(e){notice(e.message,true)}}
|
||||
async function action(payload,success){if(pending)return;pending=true;notice(payload.action==='scan'?'주변 장비를 검색하고 있습니다…':'설정을 적용하고 있습니다…');document.querySelectorAll('button,input,select').forEach(el=>el.disabled=true);try{const r=await fetch('/api/action',{method:'POST',headers:{'Content-Type':'application/json','X-Mixer-Token':token},body:JSON.stringify(payload)});const data=await r.json();if(!r.ok)throw Error(data.error);for(;;){await new Promise(r=>setTimeout(r,500));const response=await fetch('/api/jobs/'+data.job);const job=await response.json();if(!response.ok)throw Error(job.error||'작업 상태를 읽을 수 없습니다.');if(job.done){if(!job.ok)throw Error(job.message);break}}notice(success);await refresh();if(payload.action==='scan')renderDiscovered()}catch(e){notice(e.message,true)}finally{pending=false;document.querySelectorAll('button,input,select').forEach(el=>el.disabled=false);await refresh()}}
|
||||
function renderDiscovered(){$('discovered').replaceChildren();for(const dev of state.bluetooth){const row=node('div',undefined,'discoveredRow'),name=node('div');name.append(node('span',dev.name),node('small',dev.address));const btn=node('button','연결하여 출력','quiet');btn.onclick=()=>action({action:'pair-output',address:dev.address},'Bluetooth 출력 장비를 연결했습니다.');row.append(name,btn);$('discovered').append(row)}}
|
||||
$('refresh').onclick=()=>refresh();$('tabDac').onclick=()=>showTab('dac');$('tabBt').onclick=()=>showTab('bluetooth');$('useDac').onclick=()=>action({action:'output',mode:'dac',pcm:$('dacSelect').value},'DAC 출력을 선택했습니다.');$('useBt').onclick=()=>{const addr=$('btSelect').value,dev=state.bluetooth.find(d=>d.address===addr);action(dev&&!dev.bonded?{action:'pair-output',address:addr}:{action:'output',mode:'bluetooth',address:addr},'Bluetooth 출력을 선택했습니다.')};$('scan').onclick=()=>action({action:'scan'},'검색 완료. 새 장비를 연결해 주세요.');refresh(true);refreshTimer=setInterval(()=>{if(!pending)refresh()},4000);
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Radxa Mixer</title><link rel="stylesheet" href="/style.css"></head><body>
|
||||
<main><header><div><span class="eyebrow">WIRELESS AUDIO</span><h1>Radxa Mixer<span class="dot"></span></h1><p id="deviceName">연결 중…</p></div><button id="refresh" class="quiet">새로고침 ↻</button></header>
|
||||
<div id="notice" class="notice" role="status" aria-live="polite">믹서에 연결하고 있습니다.</div>
|
||||
<section><div class="sectionTitle"><h2>입력 믹서</h2><span>입력별 볼륨 · 음소거</span></div><div id="inputs" class="inputs"></div><p class="hint">AirPlay는 기존 볼륨을 조절합니다. 출력 장비의 볼륨은 변경하지 않습니다.</p></section>
|
||||
<section><div class="sectionTitle"><h2>출력 장비</h2><span id="outputState">확인 중</span></div><div class="tabs"><button id="tabDac">DAC</button><button id="tabBt">Bluetooth</button></div><div id="dacPanel"><label for="dacSelect">오디오 장치</label><div class="choose"><select id="dacSelect"></select><button id="useDac">출력 선택</button></div></div><div id="btPanel" hidden><label for="btSelect">등록한 헤드폰·스피커</label><div class="choose"><select id="btSelect"></select><button id="useBt">출력 선택</button></div><div class="scanRow"><p>새 출력 장비는 페어링 모드로 켜 주세요.</p><button id="scan" class="quiet">장비 검색</button></div><div id="discovered"></div></div><p class="hint">출력 전환 중에는 잠시 소리가 멈출 수 있습니다. 전환 후 AirPlay를 다시 선택해 주세요.</p></section>
|
||||
<footer>RADXA MIXER <span>공통 감쇠 −3 dB · 스테레오</span></footer></main><script src="/app.js"></script></body></html>
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;--bg:#0b1116;--panel:#141d24;--line:#28343e;--text:#ebf3f7;--muted:#8ea3b3;--accent:#72e4c1}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 70% 0,#17352e55,transparent 55%),var(--bg);color:var(--text);font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:960px;margin:0 auto;padding:44px 24px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:26px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:.18em;color:var(--accent)}h1{font-size:36px;letter-spacing:-1px;margin:8px 0}header p{color:var(--muted);margin:0}.dot{display:inline-block;width:9px;height:9px;background:var(--accent);border-radius:50%;margin-left:12px;vertical-align:middle}.notice{padding:13px 18px;background:#182d27;color:#b1ecda;border:1px solid #285844;border-radius:12px;margin-bottom:24px;white-space:pre-line}.notice.error{background:#332124;border-color:#6f3943;color:#ffc3cc}section{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:26px;margin:22px 0}.sectionTitle{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}h2{font-size:19px;letter-spacing:-.4px;margin:0}.sectionTitle span,.hint{color:var(--muted);font-size:12px}.inputs{display:grid;grid-template-columns:repeat(auto-fit,minmax(235px,1fr));gap:16px}.input{padding:20px;border:1px solid var(--line);border-radius:14px;background:#0c151c}.inputTop{display:flex;justify-content:space-between;gap:10px;align-items:start}.input h3{font-size:16px;margin:0 0 6px;overflow-wrap:anywhere}.kind{color:var(--muted);font-size:11px}.playState{font-size:11px;color:var(--accent)}.volumeValue{font-size:42px;font-weight:600;letter-spacing:-2px;margin:24px 0 14px}.volumeValue small{font-size:16px;color:var(--muted);margin-left:6px;letter-spacing:0}input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer;margin:8px 0 16px}.rangeLabels{display:flex;justify-content:space-between;color:var(--muted);font-size:11px}button,select{font:inherit;border:1px solid var(--line);border-radius:9px;padding:11px 16px}button{background:var(--accent);color:#08241c;font-weight:600;cursor:pointer;transition:background .15s}button:hover{filter:brightness(1.12)}button:disabled{opacity:.4;cursor:wait}.quiet,.mute{background:transparent;color:var(--text)}.mute{width:100%;margin-top:15px}.mute.on{background:#403028;border-color:#715141;color:#ffcf9e}.tabs{display:flex;gap:8px;margin-bottom:24px}.tabs button{background:#0c151c;color:var(--muted);min-width:120px}.tabs button.selected{border-color:var(--accent);color:var(--accent);background:#17332c}label{display:block;font-size:12px;color:var(--muted);margin:12px 0}.choose{display:flex;gap:12px}select{background:#0c151c;color:var(--text);flex:1;min-width:0}.scanRow{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-top:22px}.scanRow p{font-size:12px;color:var(--muted)}.discoveredRow{border-top:1px solid var(--line);padding:14px 0;display:flex;align-items:center;justify-content:space-between;gap:12px}.discoveredRow small{display:block;color:var(--muted);margin-top:4px}.hint{line-height:1.7;margin-bottom:0}footer{display:flex;justify-content:space-between;margin-top:32px;font-size:10px;letter-spacing:.1em;color:var(--muted)}[hidden]{display:none!important}@media(max-width:600px){main{padding:26px 16px}h1{font-size:29px}section{padding:20px}.sectionTitle{align-items:start;gap:10px}.sectionTitle span{text-align:right}.choose{flex-direction:column}.scanRow{align-items:start}.inputs{grid-template-columns:1fr}header{gap:12px}header button{font-size:12px;padding:9px}}
|
||||
Reference in New Issue
Block a user