music video renderer web app deftoku.digi.rip
audio visualizer webapp
1

Configure Feed

Select the types of activity you want to include in your feed.

ui: refactor into a macOS-style menu bar with export improvements

digi.rip (Jun 20, 2026, 5:53 PM EDT) 0966685f 18b5a3b8

+299 -60
+61 -43
index.html
··· 8 8 <link 9 9 rel="icon" 10 10 type="image/png" 11 - href="/assets/favicon-96x96.png?v=20260620" 11 + href="/assets/favicon-96x96.png" 12 12 sizes="96x96" 13 13 /> 14 - <link 15 - rel="icon" 16 - type="image/svg+xml" 17 - href="/assets/favicon.svg?v=20260620" 18 - /> 19 - <link rel="shortcut icon" href="/assets/favicon.ico?v=20260620" /> 14 + <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" /> 15 + <link rel="shortcut icon" href="/assets/favicon.ico" /> 20 16 <link 21 17 rel="apple-touch-icon" 22 18 sizes="180x180" 23 - href="/assets/apple-touch-icon.png?v=20260620" 19 + href="/assets/apple-touch-icon.png" 24 20 /> 25 21 <meta name="apple-mobile-web-app-title" content="deftoku" /> 26 - <link rel="manifest" href="/assets/site.webmanifest?v=20260620" /> 27 - 22 + <link rel="manifest" href="/assets/site.webmanifest" /> 28 23 <script type="module" src="/dist/dist.js"></script> 29 24 </head> 30 25 31 26 <body> 32 27 <noscript> you need to enable javascript to run this app. </noscript> 33 - <div class="main-content"> 34 - <div class="bar top-bar"> 35 - <select name="resolution" id="res-select"> 36 - <option value="1080">1080p</option> 37 - <option value="720">720p</option> 38 - <option value="480">480p</option> 39 - <option value="240">240p</option> 40 - </select> 41 - <select name="aspect-ratio" id="ratio-select"> 42 - <option value="1.7777777778">16/9</option> 43 - <option value="1.3333333333">4/3</option> 44 - <option value="1">1/1</option> 45 - </select> 46 - <select name="visualizer" id="vis-select"> 47 - <option value="cdtop">cd-top</option> 48 - <option value="cdbottom">cd-bottom</option> 49 - <option value="bars">bars</option> 50 - </select> 28 + 29 + <div class="menubar"> 30 + <div class="menu-group"> 31 + <button class="menu-trigger" data-menu="file">file</button> 32 + <div class="menu-panel hidden" id="panel-file"> 33 + <button class="menu-item" id="import">import audio</button> 34 + <button class="menu-item" id="export">export video</button> 35 + </div> 51 36 </div> 52 - <canvas id="canvas" width="1920" height="1080"></canvas> 53 - <div class="bar bottom-bar"> 54 - <button id="import" type="button">import audio</button> 55 - <select name="quality" id="quality-select"> 56 - <option value="high">high</option> 57 - <option value="medium">medium</option> 58 - <option value="low">low</option> 59 - <option value="verylow">very low</option> 60 - </select> 61 - <select name="framerate" id="fps-select"> 62 - <option value="60">60fps</option> 63 - <option value="30">30fps</option> 64 - <option value="24">24fps</option> 65 - </select> 66 - <button id="export" type="button">export video</button> 37 + 38 + <div class="menu-group"> 39 + <button class="menu-trigger" data-menu="output">output</button> 40 + <div class="menu-panel hidden" id="panel-output"> 41 + <label class="menu-label">resolution</label> 42 + <select name="resolution" id="res-select"> 43 + <option value="1080">1080p</option> 44 + <option value="720">720p</option> 45 + <option value="480">480p</option> 46 + <option value="240">240p</option> 47 + </select> 48 + <label class="menu-label">framerate</label> 49 + <select name="framerate" id="fps-select"> 50 + <option value="60">60fps</option> 51 + <option value="30">30fps</option> 52 + <option value="24">24fps</option> 53 + </select> 54 + <label class="menu-label">aspect ratio</label> 55 + <select name="aspect-ratio" id="ratio-select"> 56 + <option value="1.7777777778">16/9</option> 57 + <option value="1.3333333333">4/3</option> 58 + <option value="1">1/1</option> 59 + </select> 60 + <label class="menu-label">quality</label> 61 + <select name="quality" id="quality-select"> 62 + <option value="high">high</option> 63 + <option value="medium">medium</option> 64 + <option value="low">low</option> 65 + <option value="verylow">very low</option> 66 + </select> 67 + </div> 67 68 </div> 69 + 70 + <div class="menu-group"> 71 + <button class="menu-trigger" data-menu="visualizer"> 72 + visualizer 73 + </button> 74 + <div class="menu-panel hidden" id="panel-visualizer"> 75 + <select name="visualizer" id="vis-select"> 76 + <option value="cdtop">cd-top</option> 77 + <option value="cdbottom">cd-bottom</option> 78 + <option value="bars">bars</option> 79 + </select> 80 + </div> 81 + </div> 82 + </div> 83 + 84 + <div class="main-content"> 85 + <canvas id="canvas" width="1920" height="1080"></canvas> 68 86 </div> 69 87 </body> 70 88 </html>
+55 -2
src/controller.ts
··· 23 23 "high": QUALITY_HIGH, 24 24 }; 25 25 26 + // quick cheap preview function 26 27 function previewSine(size: number) { 27 28 const freq = new Uint8Array(size); 28 29 for (let i = 0; i < freq.length; i++) { ··· 89 90 ); 90 91 }; 91 92 93 + type ShowSaveFilePicker = ( 94 + options?: { 95 + suggestedName?: string; 96 + types?: Array<{ 97 + description?: string; 98 + accept: Record<string, string[]>; 99 + }>; 100 + }, 101 + ) => Promise<FileSystemFileHandle>; 102 + 92 103 const exportBtn = document.querySelector<HTMLButtonElement>("#export"); 93 104 exportBtn!.addEventListener("click", async () => { 94 - if (!audioBuffer) return; 105 + if (!audioBuffer) { 106 + alert("import audio first before exporting"); 107 + return; 108 + } 109 + 110 + const filename = "output.webm"; 111 + 112 + // try native "Save As" dialog 113 + if ("showSaveFilePicker" in globalThis) { 114 + let handle: FileSystemFileHandle; 115 + try { 116 + handle = await ( 117 + globalThis as unknown as { showSaveFilePicker: ShowSaveFilePicker } 118 + ).showSaveFilePicker({ 119 + suggestedName: filename, 120 + types: [ 121 + { description: "WebM Video", accept: { "video/webm": [".webm"] } }, 122 + ], 123 + }); 124 + } catch { 125 + return; // user cancelled 126 + } 127 + 128 + const fps = Number(fpsSelect.value); 129 + const quality = qualityMap[qualitySelect.value]; 130 + const drawFn = visualizers[select.value as VisualizerName]; 131 + const blob = await render(canvas, audioBuffer, drawFn, fps, quality); 132 + 133 + const writable = await handle.createWritable(); 134 + await writable.write(blob); 135 + await writable.close(); 136 + console.log("export done"); 137 + return; 138 + } 139 + 140 + // fallback: auto-download 95 141 const fps = Number(fpsSelect.value); 96 142 const quality = qualityMap[qualitySelect.value]; 97 143 const drawFn = visualizers[select.value as VisualizerName]; 98 - await render(canvas, audioBuffer, drawFn, fps, quality); 144 + const blob = await render(canvas, audioBuffer, drawFn, fps, quality); 145 + 146 + const url = URL.createObjectURL(blob); 147 + const a = document.createElement("a"); 148 + a.href = url; 149 + a.download = filename; 150 + a.click(); 151 + URL.revokeObjectURL(url); 99 152 console.log("export done"); 100 153 }); 101 154
+1 -8
src/export.ts
··· 49 49 await output.finalize(); 50 50 51 51 const finalBuffer = output.target.buffer!; 52 - const blob = new Blob([finalBuffer], { type: "video/webm" }); 53 - 54 - const url = URL.createObjectURL(blob); 55 - const a = document.createElement("a"); 56 - a.href = url; 57 - a.download = "output.webm"; 58 - a.click(); 59 - URL.revokeObjectURL(url); 52 + return new Blob([finalBuffer], { type: "video/webm" }); 60 53 }
+74
src/menubar.ts
··· 1 + function initMenubar() { 2 + const triggers = document.querySelectorAll<HTMLButtonElement>( 3 + ".menu-trigger", 4 + ); 5 + const panels = document.querySelectorAll<HTMLDivElement>(".menu-panel"); 6 + 7 + function panelFor(trigger: HTMLElement): HTMLDivElement | null { 8 + const menu = trigger.dataset.menu; 9 + if (!menu) return null; 10 + return document.querySelector(`#panel-${menu}`); 11 + } 12 + 13 + function closeAll() { 14 + panels.forEach((p) => p.classList.add("hidden")); 15 + triggers.forEach((t) => t.classList.remove("open")); 16 + } 17 + 18 + function open(trigger: HTMLButtonElement) { 19 + const panel = panelFor(trigger); 20 + if (!panel) return; 21 + panels.forEach((p) => p.classList.add("hidden")); 22 + triggers.forEach((t) => t.classList.remove("open")); 23 + panel.classList.remove("hidden"); 24 + trigger.classList.add("open"); 25 + } 26 + 27 + // click on trigger → open / toggle 28 + triggers.forEach((trigger) => { 29 + trigger.addEventListener("click", (e) => { 30 + e.stopPropagation(); 31 + if (trigger.classList.contains("open")) { 32 + closeAll(); 33 + } else { 34 + open(trigger); 35 + } 36 + }); 37 + }); 38 + 39 + // hover to switch (macOS style) 40 + triggers.forEach((trigger) => { 41 + trigger.addEventListener("mouseenter", () => { 42 + const anyOpen = document.querySelector(".menu-trigger.open"); 43 + if (anyOpen) { 44 + open(trigger); 45 + } 46 + }); 47 + }); 48 + 49 + // click outside → close 50 + document.addEventListener("click", (e) => { 51 + const clickedInside = (e.target as HTMLElement).closest(".menubar"); 52 + if (!clickedInside) { 53 + closeAll(); 54 + } 55 + }); 56 + 57 + // escape → close 58 + document.addEventListener("keydown", (e) => { 59 + if (e.key === "Escape") { 60 + closeAll(); 61 + } 62 + }); 63 + 64 + // clicks on selects/inputs inside panels shouldn't close the panel 65 + panels.forEach((panel) => { 66 + panel.addEventListener("click", (e) => e.stopPropagation()); 67 + }); 68 + } 69 + 70 + if (document.readyState === "loading") { 71 + document.addEventListener("DOMContentLoaded", initMenubar); 72 + } else { 73 + initMenubar(); 74 + }
+1
src/mod.ts
··· 1 1 import "./controller.ts"; 2 2 import "./import.ts"; 3 + import "./menubar.ts";
+107 -7
style.css
··· 20 20 font-family: 21 21 "Maple Mono", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", 22 22 "Noto Color Emoji", sans-serif; 23 + background: #111; 24 + color: #eee; 23 25 } 24 26 25 27 button, ··· 36 38 justify-content: center; 37 39 } 38 40 39 - .bar { 41 + .menubar { 40 42 display: flex; 41 43 flex-direction: row; 42 - justify-content: center; 44 + flex-wrap: wrap; 45 + align-items: center; 43 46 position: fixed; 47 + top: 0; 44 48 left: 0; 45 49 z-index: 999; 46 50 width: 100%; 47 - height: 25px; 51 + height: auto; 52 + min-height: 2rem; 53 + padding: 4px 10px; 54 + gap: 2px; 55 + background: rgba(0, 0, 0, 0.1); 56 + backdrop-filter: blur(12px); 57 + -webkit-backdrop-filter: blur(12px); 58 + border-bottom: 1px solid rgba(255, 255, 255, 0.08); 59 + user-select: none; 60 + font-size: clamp(1rem, 1.2vw, 1.8rem); 61 + } 62 + 63 + .menubar button, 64 + .menubar select { 65 + font-size: 0.82em; 66 + color: #ddd; 67 + background: none; 68 + border: 1px solid transparent; 69 + border-radius: 4px; 70 + padding: 2px 5px; 71 + cursor: pointer; 72 + white-space: nowrap; 48 73 } 49 74 50 - .top-bar { 51 - top: 10px; 75 + .menubar button:hover, 76 + .menubar select:hover { 77 + background: rgba(255, 255, 255, 0.1); 78 + border-color: rgba(255, 255, 255, 0.12); 52 79 } 53 80 54 - .bottom-bar { 55 - bottom: 10px; 81 + .menubar select { 82 + cursor: pointer; 83 + } 84 + 85 + .menubar select option { 86 + background: #222; 87 + color: #ddd; 88 + } 89 + 90 + .menu-group { 91 + position: relative; 92 + } 93 + 94 + .menu-trigger { 95 + padding: 2px 8px; 96 + border-radius: 4px; 97 + cursor: pointer; 98 + } 99 + 100 + .menu-trigger.open { 101 + background: rgba(255, 255, 255, 0.12); 102 + border-color: rgba(255, 255, 255, 0.18); 103 + } 104 + 105 + .menu-panel { 106 + position: absolute; 107 + top: 100%; 108 + left: 0; 109 + margin-top: 4px; 110 + min-width: 180px; 111 + padding: 6px 0; 112 + background: rgba(30, 30, 30, 0.9); 113 + backdrop-filter: blur(12px); 114 + -webkit-backdrop-filter: blur(12px); 115 + border: 1px solid rgba(255, 255, 255, 0.12); 116 + border-radius: 8px; 117 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); 118 + display: flex; 119 + flex-direction: column; 120 + gap: 4px; 121 + } 122 + 123 + .menu-panel.hidden { 124 + display: none; 125 + } 126 + 127 + .menu-item { 128 + display: block; 129 + width: 100%; 130 + text-align: left; 131 + padding: 4px 14px; 132 + border-radius: 0 !important; 133 + font-size: 0.82em; 134 + } 135 + 136 + .menu-item:hover { 137 + background: rgba(255, 255, 255, 0.12); 138 + } 139 + 140 + .menu-label { 141 + font-size: 0.72em; 142 + color: #888; 143 + text-transform: uppercase; 144 + letter-spacing: 0.05em; 145 + padding: 6px 14px 2px; 146 + } 147 + 148 + .menu-panel select { 149 + margin: 0 8px; 150 + width: calc(100% - 16px); 151 + padding: 4px 8px; 152 + } 153 + 154 + #export { 155 + margin-bottom: 2px; 56 156 }