mcp radio: v3 card — self-running station in one card, compact layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
This commit is contained in:
1 parent
9cff04e694
commit
e339901e5c
1 file changed
+71
-1
+71
-1
@@ -198,7 +198,7 @@ const rows=document.getElementById('rows');
|
||||
d.querySelector('button').onclick=async()=>{const s=document.getElementById('s-'+id);try{const u=src||bytes(await tool('get_track',{id:'sky'}));const a=new Audio(u);await a.play();s.textContent='playing';setTimeout(()=>a.pause(),4000)}catch(e){s.textContent='error: '+e.message}};rows.appendChild(d)});
|
||||
</script></body></html>`;
|
||||
|
||||
const RADIO_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
const RADIO_HTML_V2 = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
|
||||
body{margin:0;padding:16px 16px 18px;background:transparent;color:CanvasText}
|
||||
@@ -257,3 +257,73 @@ on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!S)show(
|
||||
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
|
||||
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.2'},appCapabilities:{}}).then(r=>{log('init ok');note('ui/notifications/initialized',{})}).catch(e=>log('init: '+e.message));
|
||||
</script></body></html>`;
|
||||
|
||||
/* v3: one card is a whole station. Claude's dj_play sheet opens it; dj_next (a small DJ model) writes each following
|
||||
sheet, pre-cooked while the current track plays. Reactions and the play log go to Claude as model context only. */
|
||||
const RADIO_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<style>
|
||||
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
|
||||
html,body{margin:0;background:transparent;color:CanvasText}body{padding:14px 16px}
|
||||
.card{display:flex;gap:16px;align-items:stretch}
|
||||
canvas{width:132px;height:132px;border-radius:14px;flex:none}
|
||||
.side{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}
|
||||
.t{font-size:16px;font-weight:600;margin:0;line-height:1.25}.g{font-size:12px;color:var(--mut);margin:0;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.st{font-size:12px;color:var(--mut)}
|
||||
.bar{height:3px;background:var(--line);border-radius:2px;overflow:hidden}.bar i{display:block;height:100%;width:0;background:CanvasText;transition:width .25s linear}
|
||||
.ctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:auto}
|
||||
button{font:inherit;font-size:13px;padding:4px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit;cursor:pointer}
|
||||
button:disabled{opacity:.4;cursor:default}button.on{border-color:CanvasText}
|
||||
label{font-size:12px;color:var(--mut);display:flex;gap:4px;align-items:center}
|
||||
.line{font-size:13px;font-style:italic;margin:10px 0 0;color:var(--mut)}
|
||||
.q{font-size:12px;color:var(--mut);margin-top:6px}
|
||||
details{margin-top:8px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;font:11px ui-monospace,monospace;max-height:160px;overflow:auto}
|
||||
@media (max-width:480px){canvas{width:96px;height:96px}}
|
||||
</style></head><body>
|
||||
<div class="card"><canvas id="cv" width="264" height="264"></canvas>
|
||||
<div class="side"><p class="t" id="title">tuning in…</p><p class="g" id="tags"></p><div class="st" id="st">waiting for the DJ</div>
|
||||
<div class="bar"><i id="pos"></i></div>
|
||||
<div class="ctl"><button id="pp" disabled>Play</button><button id="up" disabled>Like</button><button id="down" disabled>Not this</button><button id="next" disabled>Next</button>
|
||||
<label><input type="checkbox" id="auto" checked> keep playing</label></div></div></div>
|
||||
<p class="line" id="line" hidden></p>
|
||||
<div class="q" id="q"></div>
|
||||
<details><summary>log</summary><pre id="log"></pre></details>
|
||||
<script>${BRIDGE}
|
||||
const $=id=>document.getElementById(id), log=t=>{$('log').textContent+=t+'\\n';fit()};
|
||||
const MAX=12;let station='',hist=[],cur=null,nextP=null,started=false,tick=null,count=0;
|
||||
const bc=('BroadcastChannel' in window)?new BroadcastChannel('gandora-radio'):null,me=Math.random().toString(36).slice(2);
|
||||
if(bc)bc.onmessage=e=>{if(e.data&&e.data.on&&e.data.on!==me&&cur){pauseAll();$('auto').checked=false;status('paused: another card took the air')}};
|
||||
function fit(){const h=Math.ceil(document.body.getBoundingClientRect().height)+2;note('ui/notifications/size-changed',{width:Math.ceil(document.body.scrollWidth),height:h})}
|
||||
if('ResizeObserver' in window)new ResizeObserver(fit).observe(document.body);addEventListener('load',fit);
|
||||
function status(t){$('st').textContent=t}
|
||||
function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const m=String(k||'').match(/^([A-G])(#|b)?/i);if(!m)return 260;let i=n.indexOf(m[1].toUpperCase());if(m[2]==='#')i++;if(m[2]==='b')i--;return((i+12)%12)*30}
|
||||
function cover(s){const x=$('cv').getContext('2d'),h=hue(s.key),minor=/minor/i.test(s.key||''),bpm=+s.bpm||100;let r=s.seed||1;const rnd=()=>{r=(r*1103515245+12345)&0x7fffffff;return r/0x7fffffff};
|
||||
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,264,264);const n=3+Math.round(bpm/30);
|
||||
for(let i=n;i>0;i--){x.beginPath();x.arc(132+(rnd()-.5)*40,132+(rnd()-.5)*40,i*(116/n),0,7);x.strokeStyle='hsl('+((h+i*(minor?8:20))%360)+' 70% '+(55+i*3)+'%)';x.lineWidth=1.5+rnd()*3;x.stroke()}}
|
||||
function show(s){$('title').textContent=s.title||'untitled';$('tags').textContent=[s.tags,s.bpm&&(s.bpm+' bpm'),s.key].filter(Boolean).join(' · ');
|
||||
if(s.dj_line){$('line').textContent='“'+s.dj_line+'”';$('line').hidden=false}else $('line').hidden=true;cover(s);$('up').textContent='Like';$('up').classList.remove('on');fit()}
|
||||
async function cook(s){const vp=s.dj_line?tool('dj_speak',{text:s.dj_line,voice:s.voice||'af_nicole'},60000).then(bytes).catch(e=>{log('voice: '+e.message);return null}):Promise.resolve(null);
|
||||
const r=await tool('render_track',s,180000);log('cooked "'+s.title+'" via '+r.renderer);const vu=await vp;return{s,song:new Audio(bytes(r)),voice:vu?new Audio(vu):null}}
|
||||
async function cookNext(){const s=await tool('dj_next',{station,history:hist},60000);s.voice=s.voice||(cur&&cur.s.voice);$('q').textContent='up next: '+s.title;fit();return cook(s)}
|
||||
function pauseAll(){if(cur){cur.song.pause();cur.voice&&cur.voice.pause()}$('pp').textContent='Play'}
|
||||
async function context(){const lines=hist.map((h,i)=>(i+1)+'. "'+h.title+'" ('+h.bpm+' bpm, '+h.key+'): '+(h.reaction||'played'));
|
||||
try{await rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] station "'+station+'" play log, oldest first:\\n'+lines.join('\\n')}],structuredContent:{station,history:hist}})}catch(e){log('context: '+e.message)}}
|
||||
function mark(r){const h=hist[hist.length-1];if(h){h.reaction=r;context()}}
|
||||
async function air(t){pauseAll();cur=t;show(t.s);hist.push({title:t.s.title,tags:t.s.tags,bpm:t.s.bpm,key:t.s.key});count++;context();
|
||||
t.song.ontimeupdate=()=>{$('pos').style.width=(100*t.song.currentTime/(t.song.duration||1))+'%'};
|
||||
t.song.onended=()=>{if(cur!==t)return;if(!hist[hist.length-1].reaction)mark('played through');if($('auto').checked)advance();else{status('ended');$('pp').textContent='Play'}};
|
||||
if(t.voice)t.voice.onended=()=>{if(cur===t)t.song.play().catch(()=>status('tap Play'))};
|
||||
['pp','up','down','next'].forEach(i=>$(i).disabled=false);if(bc)bc.postMessage({on:me});
|
||||
try{if(t.voice)await t.voice.play();else await t.song.play();$('pp').textContent='Pause';status('on air · track '+count)}catch(e){status('tap Play to start');$('pp').textContent='Play'}
|
||||
$('q').textContent='';if(count<MAX){nextP=cookNext();nextP.catch(e=>{log('next: '+e.message);nextP=null})}else $('q').textContent='end of this set: ask the DJ for more'}
|
||||
async function advance(){if(count>=MAX){status('set over');return}const p=nextP||cookNext();nextP=null;status('cooking the next one…');try{await air(await p)}catch(e){status('next failed: '+e.message);$('next').disabled=false}}
|
||||
$('pp').onclick=()=>{if(!cur)return;const live=(cur.voice&&!cur.voice.paused)?cur.voice:(cur.voice&&cur.voice.currentTime>0&&!cur.voice.ended&&cur.song.currentTime===0?cur.voice:cur.song);
|
||||
if(!live.paused){live.pause();$('pp').textContent='Play'}else{live.play();$('pp').textContent='Pause';if(bc)bc.postMessage({on:me})}};
|
||||
$('up').onclick=()=>{mark('liked');$('up').textContent='Liked';$('up').classList.add('on')};
|
||||
$('down').onclick=()=>{mark('disliked, skipped');pauseAll();advance()};
|
||||
$('next').onclick=()=>{mark('skipped');pauseAll();advance()};
|
||||
async function begin(s){if(started)return;started=true;station=s.station||s.tags;show(s);const t0=Date.now();tick=setInterval(()=>status('cooking… '+Math.round((Date.now()-t0)/1000)+'s'),1000);
|
||||
try{const t=await cook(s);clearInterval(tick);await air(t)}catch(e){clearInterval(tick);status('render failed: '+e.message);$('next').disabled=false}}
|
||||
on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!started)show(p.arguments)});
|
||||
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
|
||||
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.3'},appCapabilities:{}}).then(()=>{log('init ok');note('ui/notifications/initialized',{});fit()}).catch(e=>log('init: '+e.message));
|
||||
</script></body></html>`;
|
||||
Reference in new issue
Block a user