File size: 21,882 Bytes
d152cbc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pocket Video Joiner</title>
<style>
:root{--bg:#0e1116;--p:#161b22;--p2:#1c232d;--l:#2a323d;--t:#e6edf3;--d:#8b98a5;--a:#4cc2ff;--warn:#d29922;--red:#7f1018}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--t);font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}body.dragging .wrap{opacity:.35;filter:blur(1px)}.wrap{max-width:1180px;margin:auto;padding:20px;transition:.15s}header{display:flex;align-items:center;gap:12px;margin-bottom:16px}h1{font-size:17px;margin:0}.sub,.by,.hint,#status{color:var(--d);font-size:12px}.spacer{flex:1}.ver{border:1px solid var(--l);border-radius:99px;padding:3px 10px;background:var(--p);color:var(--d)}button{background:var(--p2);color:var(--t);border:1px solid var(--l);border-radius:8px;padding:7px 12px;cursor:pointer}button:hover{border-color:#465467}.primary{background:var(--a);border-color:var(--a);color:#04121c;font-weight:700}.danger{color:#ffb4ae}.icon{font-size:18px;width:40px;height:34px;display:inline-flex;align-items:center;justify-content:center}.grid{display:grid;grid-template-columns:1fr 330px;gap:18px}@media(max-width:900px){.grid{grid-template-columns:1fr}}#drop{border:2px dashed var(--l);border-radius:14px;padding:38px 20px;text-align:center;background:var(--p);margin-bottom:14px;cursor:pointer}#drop.over{border-color:var(--a);background:#12212b}#drop.hidden{display:none}.card,.box{background:var(--p);border:1px solid var(--l);border-radius:10px;padding:14px}.box{background:#11161d;margin-bottom:14px}h3{margin:0 0 12px;color:var(--d);letter-spacing:.7px;font-size:12px;text-transform:uppercase}.stage{background:#000;border:1px solid var(--l);border-radius:10px;min-height:300px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}canvas{max-width:100%;max-height:62vh;background:#000}.badge{position:absolute;top:10px;left:10px;background:#000a;color:#cfe;border-radius:6px;padding:4px 9px;font-size:12px}.transport{display:flex;gap:8px;align-items:center;margin:12px 0;flex-wrap:wrap}.tc{font-variant-numeric:tabular-nums;color:var(--d)}input[type=range]{accent-color:var(--a);flex:1 1 240px}.row{display:flex;gap:8px;align-items:center;margin-bottom:10px}.row label{width:105px;color:var(--d);font-size:12px}input[type=text],input[type=number],select{width:100%;background:var(--p2);color:var(--t);border:1px solid var(--l);border-radius:7px;padding:7px}.addbar{display:none;align-items:center;justify-content:space-between;gap:8px;background:var(--p2);border:1px solid var(--l);border-radius:8px;padding:8px 10px;margin-bottom:10px;color:var(--d);font-size:12px}.addbar.show{display:flex}.clip{display:grid;grid-template-columns:30px 96px 1fr auto;gap:10px;align-items:center;border-top:1px solid var(--l);padding:8px;background:var(--p)}.clip:first-child{border-top:none}.clip.over{outline:2px solid var(--a);outline-offset:-2px}.thumb{width:96px;height:54px;object-fit:contain;background:#000;border:1px solid var(--l);border-radius:6px}.name{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{min-width:0}.small{padding:4px 8px;font-size:12px}.bar{display:none;height:6px;background:var(--p2);border-radius:99px;overflow:hidden;margin-top:10px}.bar i{display:block;height:100%;width:0;background:var(--a)}.note{color:var(--warn);font-size:11.5px;line-height:1.4}.hiddenvid{position:fixed;left:-9999px;width:2px;height:2px;opacity:.01}.empty{padding:14px;text-align:center;color:var(--d)}#dragOverlay{display:none;position:fixed;inset:0;z-index:9999;align-items:center;justify-content:center;pointer-events:none}body.dragging #dragOverlay{display:flex}.dropCard{border:3px dashed var(--a);border-radius:18px;background:#0e1116e8;color:var(--t);padding:46px 70px;text-align:center;box-shadow:0 20px 60px #000a}.dropCard h2{margin:0 0 8px;font-size:24px}.audioName{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--d);font-size:12px;margin-top:6px}</style>
</head>
<body>
<div id="dragOverlay"><div class="dropCard"><h2>Drop videos here</h2><div class="hint">Release to add them to the join list</div></div></div>
<div class="wrap">
<header><h1>Pocket Video Joiner</h1><span class="sub">join clips in order</span><span class="spacer"></span><span class="ver">v1.6</span><span class="by">By C_Nugget</span></header>
<div id="drop"><h2>Drop videos here</h2><p class="hint">or click to choose multiple files</p><input id="file" type="file" accept="video/*" multiple hidden></div>
<div class="grid"><main><div class="stage"><canvas id="c"></canvas><div id="badge" class="badge">Add videos to start</div></div><div class="transport"><button id="play">▶</button><button id="restart">Restart</button><button id="muteBtn" class="icon" title="Mute/unmute playback">🔊</button><span class="tc"><b id="now">0:00.00</b> / <span id="total">0:00.00</span></span><input id="seek" type="range" min="0" max="1000" value="0"></div><div class="box"><h3>Clip Order</h3><div id="addbar" class="addbar"><span>Drop more videos anywhere on this page, or click Add videos.</span><button id="add" class="small">+ Add videos</button></div><div id="list"><div class="empty">No videos loaded yet.</div></div></div></main><aside><div class="card"><div class="box"><h3>Text Overlay</h3><div class="row"><label>Mode</label><select id="ovMode"><option value="none">None</option><option value="clip" selected>Use each clip's text</option><option value="global">Use global text</option><option value="name">Use file name</option></select></div><div class="row"><label>Global text</label><input id="ovGlobal" type="text" placeholder="Text for all clips"></div><div class="row"><label>Position</label><select id="ovPos"><option value="tl">Top left</option><option value="tr">Top right</option><option value="bl">Bottom left</option><option value="br">Bottom right</option><option value="ct">Center top</option></select></div><p class="hint">Choose None to disable overlays. In clip mode, blank clip text falls back to global text.</p></div><div class="box"><h3>Global Audio</h3><button id="audioBtn" class="small" style="width:100%">Choose global audio</button><input id="audioFile" type="file" accept="audio/*" hidden><div id="audioName" class="audioName">No global audio selected</div><p class="hint">Playback always uses clip audio plus global audio. The speaker button mutes/unmutes both. Global audio starts at the beginning and stops when the joined video ends.</p></div><div class="box"><h3>Output</h3><div class="row"><label>Scale</label><select id="scale"><option value="1" selected>100%</option><option value=".75">75%</option><option value=".5">50%</option></select></div><div class="row"><label>FPS</label><input id="fps" type="number" value="30" min="1" max="60"></div><div class="row"><label>Export audio</label><select id="outAud"><option value="both" selected>Clip + global</option><option value="clip">Clip only</option><option value="global">Global only</option><option value="none">No audio</option></select></div><button id="export" class="primary" style="width:100%">Export joined video</button><button id="stop" class="danger" style="display:none;width:100%;margin-top:8px">Stop export</button><div id="bar" class="bar"><i></i></div><div id="status"></div><p class="note">Reliable WebM export is preferred for joining. Audio is mixed through Web Audio for export.</p></div></div></aside></div></div>
<video id="v" class="hiddenvid" playsinline preload="auto"></video><audio id="ga" preload="auto"></audio>
<script>
(()=>{
'use strict';
const $=id=>document.getElementById(id), v=$('v'), ga=$('ga'), c=$('c'), x=c.getContext('2d',{alpha:false});
let clips=[], cur=0, playing=false, exporting=false, stopExp=false, draggingSeek=false, dragDepth=0, globalAudioUrl=null, playbackMuted=false, audioCtx=null, clipSource=null, globalSource=null, mixDest=null;
const wait=(el,ev)=>new Promise(r=>el.addEventListener(ev,r,{once:true}));
const clamp=(n,a,b)=>Math.min(b,Math.max(a,n));
function tc(t){t=Math.max(0,t||0);let h=Math.floor(t/3600),m=Math.floor(t%3600/60),s=t%60;return(h?h+':':'')+(h?String(m).padStart(2,'0'):m)+':'+(s<10?'0':'')+s.toFixed(2)}
function totalDuration(){return clips.reduce((a,b)=>a+b.d,0)}
function startOf(i){let t=0;for(let n=0;n<i;n++)t+=clips[n].d;return t}
function globalTime(){return startOf(cur)+(v.currentTime||0)}
function clipAt(t){let a=0;for(let i=0;i<clips.length;i++){if(t<a+clips[i].d||i===clips.length-1)return{i,t:clamp(t-a,0,clips[i].d)};a+=clips[i].d}return{i:0,t:0}}
function dims(){if(!clips.length)return{w:1280,h:720};let sc=parseFloat($('scale').value)||1,W=Math.max(...clips.map(q=>q.w)),H=Math.max(...clips.map(q=>q.h));return{w:Math.round(W*sc),h:Math.round(H*sc)}}
function setCanvas(){let d=dims();if(c.width!==d.w||c.height!==d.h){c.width=d.w;c.height=d.h}$('total').textContent=tc(totalDuration());$('badge').textContent=clips.length?clips.length+' clips · '+tc(totalDuration()):'Add videos to start'}
function fit(){setCanvas();x.fillStyle='#000';x.fillRect(0,0,c.width,c.height);if(v.videoWidth){let s=Math.min(c.width/v.videoWidth,c.height/v.videoHeight),w=v.videoWidth*s,h=v.videoHeight*s;x.drawImage(v,(c.width-w)/2,(c.height-h)/2,w,h)}}
function overlayText(){let mode=$('ovMode').value;if(mode==='none'||!clips[cur])return'';let global=($('ovGlobal').value||'').trim();if(mode==='global')return global;if(mode==='name')return clips[cur].name;return (clips[cur].text||'').trim()||global}
function drawText(t){if(!t)return;let f=Math.max(13,Math.round(c.height/42)),px=10,py=6,g=10;x.font='600 '+f+'px -apple-system,BlinkMacSystemFont,Segoe UI,Arial';while(x.measureText(t).width>c.width-24&&t.length>3)t=t.slice(0,-2);let w=x.measureText(t).width+px*2,h=f+py*2,bx=g,by=g,p=$('ovPos').value;if(p==='tr')bx=c.width-w-g;if(p==='bl')by=c.height-h-g;if(p==='br'){bx=c.width-w-g;by=c.height-h-g}if(p==='ct')bx=(c.width-w)/2;x.fillStyle='rgba(127,16,24,.94)';x.fillRect(bx,by,w,h);x.fillStyle='#fff';x.textBaseline='top';x.fillText(t,bx+px,by+py)}
function draw(){fit();drawText(overlayText());let gt=globalTime();$('now').textContent=tc(gt);if(!draggingSeek&&totalDuration())$('seek').value=Math.round(gt/totalDuration()*1000);if(playing||exporting)requestAnimationFrame(draw)}
async function meta(video){if(video.readyState>=1)return;await Promise.race([wait(video,'loadedmetadata'),wait(video,'error'),new Promise(r=>setTimeout(r,8000))])}
async function data(video){if(video.readyState>=2)return;await Promise.race([wait(video,'loadeddata'),wait(video,'canplay'),new Promise(r=>setTimeout(r,3000))])}
async function makeThumb(cl){let tv=document.createElement('video');tv.src=cl.url;tv.muted=true;tv.playsInline=true;await data(tv);let ca=document.createElement('canvas');ca.width=192;ca.height=108;let q=ca.getContext('2d');q.fillStyle='#000';q.fillRect(0,0,192,108);if(tv.videoWidth){let s=Math.min(192/tv.videoWidth,108/tv.videoHeight),w=tv.videoWidth*s,h=tv.videoHeight*s;q.drawImage(tv,(192-w)/2,(108-h)/2,w,h)}cl.thumb=ca.toDataURL('image/jpeg',.82)}
async function addFiles(files){let added=0;for(let f of files){if(!f.type.startsWith('video/'))continue;let u=URL.createObjectURL(f),tv=document.createElement('video');tv.src=u;tv.preload='metadata';tv.playsInline=true;await meta(tv);if(!tv.videoWidth||!isFinite(tv.duration)||tv.duration<=0){URL.revokeObjectURL(u);continue}let cl={id:Date.now()+Math.random(),url:u,name:f.name.replace(/\.[^.]+$/,''),d:tv.duration,w:tv.videoWidth,h:tv.videoHeight,text:'',thumb:''};await makeThumb(cl);clips.push(cl);added++}render();if(clips.length){await load(Math.max(0,clips.length-added),0);$('status').textContent=added?('Added '+added+' video'+(added===1?'':'s')+'.'):'No readable videos found.'}else $('status').textContent='No readable videos found.'}
function render(){let list=$('list');list.innerHTML='';$('drop').classList.toggle('hidden',clips.length>0);$('addbar').classList.toggle('show',clips.length>0);if(!clips.length){list.innerHTML='<div class="empty">No videos loaded yet.</div>';setCanvas();return}clips.forEach((cl,i)=>{let row=document.createElement('div');row.className='clip';row.draggable=true;row.innerHTML=`<div>${i+1}</div><img class="thumb" src="${cl.thumb}"><div class="meta"><div class="name">${cl.name}</div><div class="hint">${tc(cl.d)} · ${cl.w}×${cl.h}</div><input type="text" placeholder="Overlay text for this clip" value="${cl.text.replace(/"/g,'"')}"></div><div><button class="small up">↑</button><button class="small dn">↓</button><button class="small pl">▶</button><button class="small danger del">✕</button></div>`;row.querySelector('input').oninput=e=>{cl.text=e.target.value;draw()};row.querySelector('.up').onclick=()=>move(i,i-1);row.querySelector('.dn').onclick=()=>move(i,i+1);row.querySelector('.pl').onclick=async()=>{pause();await load(i,0);play()};row.querySelector('.del').onclick=()=>{URL.revokeObjectURL(cl.url);clips.splice(i,1);cur=Math.min(cur,clips.length-1);render();clips.length?load(Math.max(cur,0),0):draw()};row.ondragstart=e=>e.dataTransfer.setData('text/plain',i);row.ondragover=e=>{e.preventDefault();row.classList.add('over')};row.ondragleave=()=>row.classList.remove('over');row.ondrop=e=>{e.preventDefault();row.classList.remove('over');move(+e.dataTransfer.getData('text/plain'),i)};list.appendChild(row)});setCanvas()}
function move(a,b){if(b<0||b>=clips.length)return;let [q]=clips.splice(a,1);clips.splice(b,0,q);if(cur===a)cur=b;render();draw()}
function updateMuteButton(){v.muted=playbackMuted;ga.muted=playbackMuted;$('muteBtn').textContent=playbackMuted?'🔇':'🔊'}
async function load(i,t){if(!clips[i])return;cur=i;v.src=clips[i].url;v.muted=playbackMuted;await data(v);try{v.currentTime=clamp(t,0,clips[i].d);await Promise.race([wait(v,'seeked'),new Promise(r=>setTimeout(r,500))])}catch(e){}draw()}
async function play(){if(!clips.length||exporting)return;updateMuteButton();playing=true;$('play').textContent='❚❚';draw();try{await v.play();if(globalAudioUrl){ga.currentTime=Math.min(globalTime(),ga.duration||globalTime());await ga.play().catch(()=>{})}}catch(e){$('status').textContent='Playback blocked. Click again.'}}
function pause(){playing=false;$('play').textContent='▶';v.pause();ga.pause();draw()}
v.onended=async()=>{if(cur<clips.length-1){await load(cur+1,0);if(playing||exporting){await v.play().catch(()=>{});if(globalAudioUrl)ga.play().catch(()=>{})}}else{stopExp=exporting;pause();ga.pause()}}
async function seekTo(t){let p=clipAt(t);pause();await load(p.i,p.t);if(globalAudioUrl)ga.currentTime=Math.min(t,ga.duration||t)}
function getMime(){for(let m of ['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm','video/mp4;codecs=avc1.42E01E,mp4a.40.2','video/mp4'])if(MediaRecorder.isTypeSupported(m))return m;return''}
async function addAudioTracks(stream,mode){
if(mode==='none')return false;
const AC=window.AudioContext||window.webkitAudioContext;
if(!AC)return false;
if(!audioCtx){audioCtx=new AC();mixDest=audioCtx.createMediaStreamDestination()}
if(audioCtx.state==='suspended')await audioCtx.resume().catch(()=>{});
if(!clipSource)clipSource=audioCtx.createMediaElementSource(v);
if(!globalSource)globalSource=audioCtx.createMediaElementSource(ga);
try{clipSource.disconnect()}catch(e){}
try{globalSource.disconnect()}catch(e){}
if(mode==='both'||mode==='clip'){
clipSource.connect(mixDest);
clipSource.connect(audioCtx.destination);
}
if((mode==='both'||mode==='global')&&globalAudioUrl){
globalSource.connect(mixDest);
globalSource.connect(audioCtx.destination);
}
const tracks=mixDest.stream.getAudioTracks();
tracks.forEach(t=>stream.addTrack(t));
return tracks.length>0;
}
async function saveBlob(blob,ext){let name='joined_video.'+ext;if(typeof window.showSaveFilePicker==="function"){try{let h=await showSaveFilePicker({suggestedName:name});let w=await h.createWritable();await w.write(blob);await w.close();return}catch(e){if(e.name==='AbortError')return}}let a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;a.click()}
async function exportVideo(){
if(!clips.length||exporting)return;
if(!c.captureStream||!window.MediaRecorder){$('status').textContent='Use Chrome or Edge for export.';return}
let mt=getMime();
if(!mt){$('status').textContent='No recorder support in this browser.';return}
const chosenMode=$('outAud').value;
const chosenFps=Math.max(1,Math.min(60,parseInt($('fps').value)||30));
async function recordOnce(mimeType,mode){
stopExp=false;pause();await load(0,0);draw();
$('export').disabled=true;$('stop').style.display='block';$('bar').style.display='block';$('bar').firstElementChild.style.width='0%';
$('status').textContent='Exporting... keep this tab visible.';
let stream=c.captureStream(0), videoTrack=stream.getVideoTracks()[0];
try{if(mode!=='none')addAudioTracks(stream,mode)}catch(e){mode='none'}
let rec,chunks=[];
try{rec=new MediaRecorder(stream,{mimeType:mimeType,videoBitsPerSecond:Math.max(1200000,Math.round(c.width*c.height*chosenFps*0.12))})}
catch(e){stream.getTracks().forEach(t=>t.stop());throw e}
rec.ondataavailable=e=>{if(e.data&&e.data.size)chunks.push(e.data)};
let stopped=new Promise(r=>rec.onstop=r);
try{rec.start(500)}catch(e){stream.getTracks().forEach(t=>t.stop());throw e}
exporting=true;playing=true;$('play').textContent='❚❚';
v.muted=(mode==='none');
ga.muted=(mode==='none');
if((mode==='both'||mode==='global')&&globalAudioUrl){ga.currentTime=0;await ga.play().catch(()=>{})}
await v.play().catch(()=>{});
let stoppedAlready=false;
function stopNow(){
if(stoppedAlready)return;stoppedAlready=true;
try{rec.requestData()}catch(e){}
try{rec.stop()}catch(e){}
}
let frameMs=Math.max(16,Math.round(1000/chosenFps));
let frameTimer=setInterval(()=>{
draw();
try{if(videoTrack&&videoTrack.requestFrame)videoTrack.requestFrame()}catch(e){}
let p=totalDuration()?globalTime()/totalDuration():0;
$('bar').firstElementChild.style.width=Math.min(100,p*100)+'%';
if(stopExp||p>=.999)stopNow();
},frameMs);
try{if(videoTrack&&videoTrack.requestFrame)videoTrack.requestFrame()}catch(e){}
await stopped;
clearInterval(frameTimer);
stream.getTracks().forEach(t=>{try{t.stop()}catch(e){}});
ga.pause();v.pause();
return {chunks,type:mimeType.split(';')[0],mode};
}
try{
exporting=true;
let result;
try{result=await recordOnce(mt,chosenMode)}
catch(e){
$('status').textContent='Audio export failed, retrying video-only WebM...';
result=await recordOnce('video/webm','none');
}
if(!result.chunks.length){
$('status').textContent='Empty export detected, retrying video-only WebM...';
result=await recordOnce('video/webm','none');
}
exporting=false;playing=false;$('export').disabled=false;$('stop').style.display='none';$('bar').style.display='none';$('play').textContent='▶';updateMuteButton();
if(!result.chunks.length){$('status').textContent='Export produced empty file. Try Scale 50% and Export audio: No audio.';return}
let ext=result.type.includes('mp4')?'mp4':'webm';
await saveBlob(new Blob(result.chunks,{type:result.type}),ext);
$('status').textContent='Export complete'+(result.mode==='none'?' (no audio)':'');
draw();
}catch(e){
exporting=false;playing=false;$('export').disabled=false;$('stop').style.display='none';$('bar').style.display='none';$('play').textContent='▶';updateMuteButton();
$('status').textContent='Export failed: '+(e&&e.message?e.message:'unknown error');
}
}
function isFileDrag(e){return e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')}
function dropped(e){e.preventDefault();dragDepth=0;document.body.classList.remove('dragging');$('drop').classList.remove('over');let fs=[...(e.dataTransfer?.files||[])];if(fs.length)addFiles(fs)}
$('drop').onclick=()=>$('file').click();$('add').onclick=()=>$('file').click();$('file').onchange=e=>{let fs=[...e.target.files];e.target.value='';if(fs.length)addFiles(fs)};$('audioBtn').onclick=()=>$('audioFile').click();$('audioFile').onchange=e=>{let f=e.target.files[0];if(!f)return;if(globalAudioUrl)URL.revokeObjectURL(globalAudioUrl);globalAudioUrl=URL.createObjectURL(f);ga.src=globalAudioUrl;ga.muted=playbackMuted;$('audioName').textContent=f.name;e.target.value=''};
['dragenter','dragover'].forEach(ev=>$('drop').addEventListener(ev,e=>{e.preventDefault();$('drop').classList.add('over')}));['dragleave','drop'].forEach(ev=>$('drop').addEventListener(ev,e=>{e.preventDefault();$('drop').classList.remove('over')}));$('drop').addEventListener('drop',dropped);
window.addEventListener('dragenter',e=>{if(isFileDrag(e)){dragDepth++;document.body.classList.add('dragging')}});window.addEventListener('dragover',e=>{if(isFileDrag(e)){e.preventDefault();document.body.classList.add('dragging')}});window.addEventListener('dragleave',e=>{dragDepth=Math.max(0,dragDepth-1);if(dragDepth===0)document.body.classList.remove('dragging')});window.addEventListener('drop',dropped);
$('play').onclick=()=>playing?pause():play();$('restart').onclick=()=>seekTo(0);$('muteBtn').onclick=()=>{playbackMuted=!playbackMuted;updateMuteButton()};$('seek').onpointerdown=()=>draggingSeek=true;$('seek').onpointerup=()=>{draggingSeek=false;seekTo(totalDuration()*$('seek').value/1000)};$('seek').oninput=()=>{if(draggingSeek)$('now').textContent=tc(totalDuration()*$('seek').value/1000)};$('export').onclick=exportVideo;$('stop').onclick=()=>stopExp=true;['scale','fps','ovMode','ovGlobal','ovPos'].forEach(id=>{$(id).oninput=draw;$(id).onchange=draw});
updateMuteButton();render();draw();
})();
</script>
</body>
</html> |