Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f42fa1d28c | |||
| db644cec6f | |||
| cca4aed380 | |||
| 2402e649a2 | |||
| f0771ff7a5 | |||
| 277625f90c | |||
| b24cfab15f | |||
| ec562c4071 | |||
| e0f6940081 | |||
| 87d719bbfb | |||
| a3f6b83297 |
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NAM Pedal — Helix Stadium UX Concept</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Pi Multi-FX — Helix Stadium Mockup</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
|
||||
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; display: flex; justify-content: center; min-height: 100vh; }
|
||||
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Space Grotesk', sans-serif; display: flex; justify-content: center; min-height: 100vh; }
|
||||
|
||||
.device {
|
||||
width: 100%;
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
|
||||
<meta name="theme-color" content="#0A0A0C" />
|
||||
<title>NAM Pedal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||
<g id="Layer_1">
|
||||
<g>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,14 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||
<g id="Layer_1" display="none">
|
||||
<path display="inline" d="M47,41c0,3.313-2.687,6-6,6H7c-3.313,0-6-2.687-6-6V7c0-3.313,2.687-6,6-6h34c3.313,0,6,2.687,6,6V41z"/>
|
||||
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="23.5" y1="10" x2="23.5" y2="22"/>
|
||||
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="43" y1="13.546" x2="19.938" y2="53.489"/>
|
||||
<line display="inline" fill="none" stroke="#FFFFFF" stroke-width="5" x1="5" y1="13.545" x2="11.904" y2="25.504"/>
|
||||
</g>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="48px"
|
||||
height="48px" viewBox="0 0 48 48" fill="#F0EDE6">
|
||||
<g id="Combined">
|
||||
<path d="M41,1H7C3.687,1,1,3.687,1,7v34c0,3.313,2.687,6,6,6h13.798l20.037-34.704l4.33,2.5L26.571,47H41c3.313,0,6-2.687,6-6V7
|
||||
C47,3.687,44.313,1,41,1z M9.739,26.754L2.835,14.795l4.33-2.5l6.904,11.958L9.739,26.754z M26,22h-5V10h5V22z"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 450 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||
<g id="Layer_1">
|
||||
<path d="M31.5,2.262v8.767C35.976,13.628,39,18.462,39,24c0,8.271-6.729,15-15,15c-8.271,0-15-6.729-15-15
|
||||
|
||||
|
Before Width: | Height: | Size: 857 B After Width: | Height: | Size: 872 B |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg fill="#F0EDE6" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 477 B After Width: | Height: | Size: 492 B |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
height="48px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||
<g id="Layer_2">
|
||||
<g>
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
<svg fill="#F0EDE6" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||
<g id="x">
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
<svg fill="#F0EDE6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="48px"
|
||||
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||
<g id="Combined">
|
||||
<path d="M35.819,27C34.863,32.666,29.935,37,24,37c-4.43,0-8.298-2.419-10.376-6h5.103c1.41,1.24,3.253,2,5.274,2
|
||||
|
||||
|
Before Width: | Height: | Size: 900 B After Width: | Height: | Size: 915 B |
@@ -1,8 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
|
||||
<path d="M18.993,8.993L18.99,5c0-1.1-0.891-2-1.99-2h-4h-3H6C4.9,3,4.01,3.9,4.01,5L4.007,8.993L4,9v10c0,1.1,0.891,2,1.99,2H6h11
|
||||
h0.01c1.1,0,1.99-0.9,1.99-2V9L18.993,8.993z M17,10v9H6v-9V5h5h1h5V10z"/>
|
||||
<rect x="8" y="7" width="2" height="2"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 647 B |
@@ -1,8 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
|
||||
<path d="M20.953,7.993L20.949,4c0-1.1-0.891-2-1.99-2h-4h-3h-3c-1.1,0-1.99,0.9-1.99,2h1.99h4h1h5h0.006v16h0.004
|
||||
c1.1,0,1.99-0.9,1.99-2V8L20.953,7.993z"/>
|
||||
<path d="M17.993,10.993L17.99,7c0-1.1-0.891-2-1.99-2h-4H9H6C4.9,5,4.01,5.9,4.01,7l-0.003,3.993L4,11v10c0,1.1,0.891,2,1.99,2H6h10
|
||||
|
||||
|
Before Width: | Height: | Size: 919 B After Width: | Height: | Size: 627 B |
@@ -31,8 +31,12 @@ const API = {
|
||||
listPresets: () => api("GET", "/api/presets"),
|
||||
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
|
||||
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
|
||||
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
|
||||
setAudioProfile:(rate, period) => api("POST", "/api/audio/profile", { rate: parseInt(rate), period: parseInt(period) }),
|
||||
savePreset: (bank, program, name, tags) => api("PUT", `/api/presets/${bank}/${program}`, { name, chain: [], master_volume: 0.8, tags }),
|
||||
setAudioProfile:(rate, period) => api("POST", "/api/audio/profile", { profile: "stable", rate: parseInt(rate), period: parseInt(period) }),
|
||||
getAudioProfile:() => api("GET", "/api/audio/profile"),
|
||||
getRouting: () => api("GET", "/api/routing"),
|
||||
setRouting: (mode, breakpoint) => api("POST", "/api/routing", { routing_mode: mode, routing_breakpoint: breakpoint }),
|
||||
saveSettings: (settings) => api("POST", "/api/settings", settings),
|
||||
searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
|
||||
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?q=${encodeURIComponent(q)}`),
|
||||
installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }),
|
||||
@@ -48,13 +52,13 @@ const T = {
|
||||
border: "#2A2A32",
|
||||
amber: "#E8A030",
|
||||
amberDim: "#7A5218",
|
||||
blue: "#3A7BA8",
|
||||
blue: "#4A8BC8",
|
||||
blueDim: "#1E4060",
|
||||
green: "#3AB87A",
|
||||
red: "#C84040",
|
||||
red: "#E06060",
|
||||
textPrimary: "#F0EDE6",
|
||||
textSec: "#8888A0",
|
||||
textDim: "#444458",
|
||||
textSec: "#A0A0BC",
|
||||
textDim: "#7878A0",
|
||||
};
|
||||
|
||||
// ── Block parameter definitions ─────────────────────────────────
|
||||
@@ -96,7 +100,7 @@ const BLOCK_COLORS = {
|
||||
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
|
||||
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
|
||||
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
|
||||
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', boost: T.amber,
|
||||
cabinet: '#D07090', ir: '#D07090', nam: '#E0A040', nam_amp: '#E0A040', boost: T.amber,
|
||||
wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0',
|
||||
split: '#B080C0', merge: '#80B0C0',
|
||||
};
|
||||
@@ -106,7 +110,7 @@ const BLOCK_DISPLAY_NAMES = {
|
||||
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
|
||||
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
|
||||
tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate",
|
||||
eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model",
|
||||
eq: "EQ", cabinet: "Cabinet", ir: "IR Loader", nam: "NAM Model", nam_amp: "NAM Model",
|
||||
boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner",
|
||||
loop: "FX Loop",
|
||||
split: "Split", merge: "Merge",
|
||||
@@ -135,10 +139,10 @@ function getBlockDisplayName(block) {
|
||||
|
||||
// ── CSS ─────────────────────────────────────────────────────
|
||||
const css = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Inter:wght@300;400;500;600&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600&display=swap');
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
|
||||
html, body { height: 100%; overflow: hidden; background: ${T.bg}; }
|
||||
body { color: ${T.textPrimary}; font-family: 'Inter', sans-serif; }
|
||||
body { color: ${T.textPrimary}; font-family: 'Space Grotesk', sans-serif; }
|
||||
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
@@ -163,11 +167,13 @@ const css = `
|
||||
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
|
||||
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
|
||||
.btn-ghost:active { background: ${T.border}; }
|
||||
.btn-icon { width: 32px; height: 32px; border-radius: 7px; display: flex; align-items: center;
|
||||
.btn-icon { width: 36px; height: 36px; border-radius: 7px; display: flex; align-items: center;
|
||||
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
|
||||
font-size: 15px; transition: all .12s; flex-shrink: 0; }
|
||||
.btn-icon:active { transform: scale(.93); }
|
||||
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
|
||||
.btn:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
|
||||
.btn-icon:focus-visible { outline: 2px solid #3AB87A; outline-offset: 2px; }
|
||||
|
||||
/* ── Badge ── */
|
||||
.badge { display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -175,7 +181,7 @@ const css = `
|
||||
letter-spacing: .06em; text-transform: uppercase; }
|
||||
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
|
||||
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
|
||||
.badge-blue { background: rgba(58,123,168,.2); color: ${T.blue}; }
|
||||
.badge-blue { background: rgba(74,139,200,.2); color: ${T.blue}; }
|
||||
|
||||
/* ── Param slider ── */
|
||||
.param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; }
|
||||
@@ -220,6 +226,32 @@ const css = `
|
||||
color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; }
|
||||
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; }
|
||||
.card-sm { background: ${T.surface}; border: 1px solid ${T.border}; border-radius: 6px; padding: 10px; }
|
||||
|
||||
/* ── Responsive: narrow screens ── */
|
||||
@media (max-width: 640px) {
|
||||
.resp-hide-narrow { display: none !important; }
|
||||
.resp-status-label { font-size: 8px; }
|
||||
.resp-container { max-width: 100% !important; }
|
||||
.resp-tight-pad { padding: 6px 10px; }
|
||||
.param-panel { padding: 6px 10px 5px; }
|
||||
.param-knobs { gap: 6px; }
|
||||
.screen-header { padding: 8px 10px 6px; }
|
||||
.screen-title { font-size: 11px; }
|
||||
.screen-body { padding: 8px 10px; gap: 8px; }
|
||||
.knob-wrap { transform: scale(.9); }
|
||||
.btn-icon { width: 24px; height: 24px; font-size: 10px; }
|
||||
}
|
||||
|
||||
/* ── Responsive: landscape short ── */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.resp-status-bar { min-height: 26px; padding: 2px 10px; }
|
||||
.resp-param-thumb { height: 26px; }
|
||||
}
|
||||
|
||||
/* ── Responsive: medium screens (tablet portrait / landscape phone) ── */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.resp-sidebar-compact { max-width: 70px; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Knob ──────────────────────────────────────────────────────
|
||||
@@ -391,11 +423,12 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
|
||||
|
||||
// ── VU Meter ──────────────────────────────────────────────────
|
||||
function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs);
|
||||
const segH=Math.max(3,(height-segs*1.5)/segs);
|
||||
const c=i=>i>=10?T.red:i>=8?"#E8C030":T.green;
|
||||
if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1,height,alignItems:"center"}}>
|
||||
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:(height-segs)/segs,borderRadius:1,background:i<active?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"background .05s"}}/>))}</div>);
|
||||
if(vertical)return(<div style={{display:"flex",flexDirection:"column-reverse",gap:1.5,height,alignItems:"center"}}>
|
||||
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{width:4,height:segH,borderRadius:1.5,background:i<active?c(i):T.border,boxShadow:i<active?`0 0 4px ${c(i)}`:"none",transition:"all .2s ease, opacity .15s"}}/>))}</div>);
|
||||
return(<div style={{display:"flex",gap:2,height:6,alignItems:"center",width:height}}>
|
||||
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"background .05s"}}/>))}</div>);
|
||||
{Array.from({length:segs}).map((_,i)=>(<div key={i} style={{flex:1,height:"100%",borderRadius:1,background:i<active?c(i):T.border,transition:"all .2s ease"}}/>))}</div>);
|
||||
}
|
||||
|
||||
// ── Captures Screen ──────────────────────────────────────────
|
||||
@@ -406,7 +439,7 @@ function CapturesScreen({onClose}){
|
||||
const doSearch=async()=>{if(!query.trim())return;setLoading(true);try{const data=tab==="models"?await API.searchModels(query):await API.searchIrs(query);setResults(data.results||[]);}catch{setResults([]);}setLoading(false);};
|
||||
const install=async(item)=>{try{if(tab==="models"){await API.installModel(item.download_url,item.name);}else{await API.installIr(item.download_url,item.name);}API.listModels().then(d=>setModels(d.models||[])).catch(()=>{});}catch(e){alert("Install failed: "+e.message);}};
|
||||
return(<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
|
||||
<div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:28,height:28,fontSize:13}}onClick={onClose}>✕</button></div>
|
||||
<div className="screen-header"><div className="screen-title">Downloads</div><button className="btn-icon" style={{width:36,height:36,fontSize:13}}onClick={onClose}>✕</button></div>
|
||||
<div style={{padding:"6px 12px",borderBottom:`1px solid ${T.border}`}}><div className="toggle">
|
||||
<button className={`toggle-btn ${tab==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button>
|
||||
<button className={`toggle-btn ${tab==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div>
|
||||
@@ -428,18 +461,17 @@ function CapturesScreen({onClose}){
|
||||
|
||||
// ── MOCK DATA (used when pedal not connected) ────────────────
|
||||
const MOCK_BLOCKS=[
|
||||
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25,depth:70,mix:100}},
|
||||
{id:"split",type:"split",name:"Split",bypassed:false,params:{splitType:"y",panA:50,panB:50,activePath:"A"}},
|
||||
{id:"drive_a",type:"overdrive",name:"OD-808",path:"A",bypassed:false,params:{drive:65,tone:55,level:70}},
|
||||
{id:"delay_a",type:"delay",name:"Digital Delay",path:"A",bypassed:false,params:{time:45,feedback:25,mix:40}},
|
||||
{id:"fuzz_b",type:"fuzz",name:"Big Muff",path:"B",bypassed:false,params:{fuzz:75,tone:40,level:65}},
|
||||
{id:"merge",type:"merge",name:"Merge",bypassed:false,params:{blend:100,levelA:100,panA:0,levelB:100,panB:0}},
|
||||
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{decay:60,mix:35,tone:50}},
|
||||
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25}},
|
||||
{id:"comp",type:"compressor",name:"Compressor",bypassed:false,params:{threshold:50,ratio:4,gain:50}},
|
||||
{id:"amp",type:"nam",name:"Twin Reverb",bypassed:false,params:{level:75}},
|
||||
{id:"cab",type:"ir",name:"2x12 American",bypassed:false,params:{level:75}},
|
||||
{id:"eq",type:"eq",name:"EQ",bypassed:false,params:{low:0,mid:0,high:0,level:70}},
|
||||
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{subtype:"hall",decay:50,mix:30,tone:50}},
|
||||
];
|
||||
|
||||
const MOCK_PRESETS=[
|
||||
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Modern Hi-Gain"},
|
||||
{num:3,bank:1,name:"Clean Chime"},{num:4,bank:1,name:"Blues Drive"},
|
||||
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Blues Rock Lead"},
|
||||
{num:3,bank:1,name:"Deluxe Clean"},{num:4,bank:1,name:"British Drive"},
|
||||
{num:5,bank:2,name:"Metal Djent"},{num:6,bank:2,name:"Funky Wah"},
|
||||
{num:7,bank:2,name:"Ambient Pad"},{num:8,bank:2,name:"Shoegaze"},
|
||||
];
|
||||
@@ -459,8 +491,26 @@ export default function App(){
|
||||
const[globalBypass,setGlobalBypass]=useState(false);
|
||||
const[loadingPreset,setLoadingPreset]=useState(false);
|
||||
const[focusBlockId,setFocusBlockId]=useState(null);
|
||||
const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
|
||||
const[footswitchMode,setFootswitchMode]=useState("stomp");
|
||||
const [modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
|
||||
const [footswitchMode,setFootswitchMode]=useState("stomp");
|
||||
const [addBlockType,setAddBlockType]=useState(null); // null | "picker"
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
// ── Responsive breakpoints ──
|
||||
const [isNarrow, setIsNarrow] = useState(window.innerWidth < 640);
|
||||
const [isLandscapeShort, setIsLandscapeShort] = useState(window.innerWidth > window.innerHeight && window.innerHeight < 500);
|
||||
|
||||
useEffect(() => {
|
||||
const mqNarrow = window.matchMedia("(max-width: 640px)");
|
||||
const mqShort = window.matchMedia("(orientation: landscape) and (max-height: 500px)");
|
||||
const onNarrow = (e) => setIsNarrow(e.matches);
|
||||
const onShort = (e) => setIsLandscapeShort(e.matches);
|
||||
mqNarrow.addEventListener("change", onNarrow);
|
||||
mqShort.addEventListener("change", onShort);
|
||||
// Initialize sidebar state
|
||||
if (mqNarrow.matches) setSidebarOpen(false);
|
||||
return () => { mqNarrow.removeEventListener("change", onNarrow); mqShort.removeEventListener("change", onShort); };
|
||||
}, []);
|
||||
|
||||
// ── Undo/Redo ──
|
||||
const [undoStack, setUndoStack] = useState([]);
|
||||
@@ -543,6 +593,7 @@ export default function App(){
|
||||
// ── Main Menu & Settings ──
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null
|
||||
const [overlayView, setOverlayView] = useState(null); // "save" | "bypass" | "wifi" | null
|
||||
|
||||
const handleMenuSelect = useCallback((itemId) => {
|
||||
setMenuOpen(false);
|
||||
@@ -553,8 +604,14 @@ export default function App(){
|
||||
setState(prev => ({ ...prev, tuner_enabled: true }));
|
||||
API.tunerToggle(true).catch(() => {});
|
||||
break;
|
||||
default:
|
||||
// save, bypass, wifi — placeholder for now
|
||||
case "save":
|
||||
setOverlayView("save");
|
||||
break;
|
||||
case "bypass":
|
||||
setOverlayView("bypass");
|
||||
break;
|
||||
case "wifi":
|
||||
setOverlayView("wifi");
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
@@ -581,7 +638,7 @@ export default function App(){
|
||||
useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
|
||||
|
||||
// VU animation fallback when no real data
|
||||
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.45)*8)));},150);return()=>clearInterval(id);},[]);
|
||||
useEffect(()=>{const id=setInterval(()=>{setVuLevel(p=>Math.max(0,Math.min(100,p+(Math.random()-0.5)*50+3)));},120);return()=>clearInterval(id);},[]);
|
||||
|
||||
// ── Load presets from API ──
|
||||
useEffect(()=>{
|
||||
@@ -646,7 +703,7 @@ export default function App(){
|
||||
const p=presets.find(pr=>pr.num===num);
|
||||
if(!p)return;
|
||||
setCurrentPreset(num);
|
||||
await handleLoadPreset(p.bank||1,num-1);
|
||||
await handleLoadPreset((p.bank||1)-1,num-1);
|
||||
},[presets,handleLoadPreset]);
|
||||
|
||||
// ── Blocks ──
|
||||
@@ -678,9 +735,18 @@ export default function App(){
|
||||
setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});
|
||||
},[takeSnapshot]);
|
||||
const handleAddBlock=useCallback(()=>{
|
||||
setAddBlockType("picker");
|
||||
},[takeSnapshot]);
|
||||
const handleAddBlockType=useCallback((type)=>{
|
||||
takeSnapshot();
|
||||
userChangedBlock.current=true;const id="block_"+Date.now();
|
||||
setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);
|
||||
userChangedBlock.current=true;
|
||||
const id="block_"+Date.now();
|
||||
const defs=getBlockParams(type);
|
||||
const init={};
|
||||
defs.forEach(p=>{init[p.key]=p.def;});
|
||||
const displayName=BLOCK_DISPLAY_NAMES[type]||type.charAt(0).toUpperCase()+type.slice(1);
|
||||
setBlocks(prev=>[...prev,{id,type,name:displayName,bypassed:false,params:init}]);
|
||||
setAddBlockType(null);
|
||||
setTimeout(()=>{userChangedBlock.current=false;},3000);
|
||||
},[takeSnapshot]);
|
||||
const handleRemoveBlock=useCallback((id)=>{
|
||||
@@ -699,14 +765,14 @@ export default function App(){
|
||||
const next=Math.min(currentBank+1,8);
|
||||
setCurrentBank(next);
|
||||
const bp=presets.filter(p=>p.bank===next);
|
||||
if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1);
|
||||
if(bp.length>0)handleLoadPreset(bp[0].bank-1,bp[0].num-1);
|
||||
},[currentBank,presets,handleLoadPreset]);
|
||||
|
||||
const handleBankDown=useCallback(()=>{
|
||||
const next=Math.max(currentBank-1,1);
|
||||
setCurrentBank(next);
|
||||
const bp=presets.filter(p=>p.bank===next);
|
||||
if(bp.length>0)handleLoadPreset(bp[0].bank,bp[0].num-1);
|
||||
if(bp.length>0)handleLoadPreset(bp[0].bank-1,bp[0].num-1);
|
||||
},[currentBank,presets,handleLoadPreset]);
|
||||
|
||||
const handleGlobalBypass=useCallback(()=>{
|
||||
@@ -771,19 +837,24 @@ export default function App(){
|
||||
return(
|
||||
<><style>{css}</style>
|
||||
<div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
|
||||
fontFamily:"'Inter',sans-serif",margin:"0 auto",
|
||||
maxWidth:980,
|
||||
fontFamily:"'Space Grotesk',sans-serif",margin:"0 auto",
|
||||
maxWidth:isNarrow?undefined:980,
|
||||
}}>
|
||||
|
||||
{view==="main"&&(
|
||||
<>
|
||||
{/* ══════ STATUS BAR ══════ */}
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",
|
||||
padding:"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:32}}>
|
||||
<div className="resp-status-bar" style={{display:"flex",justifyContent:"space-between",alignItems:"center",
|
||||
padding:isLandscapeShort?"2px 10px":"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:isLandscapeShort?26:32}}>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
{/* Sidebar toggle (narrow only) */}
|
||||
{isNarrow&&<button className="btn-icon" style={{width:24,height:24,fontSize:13,position:'relative',marginRight:-2}}
|
||||
onClick={()=>setSidebarOpen(s=>!s)} title="Presets">
|
||||
<span style={{fontSize:12,lineHeight:1}}>▤</span>
|
||||
</button>}
|
||||
<div style={{width:6,height:6,borderRadius:"50%",
|
||||
background:state.connected?T.green:T.red,boxShadow:`0 0 6px ${state.connected?T.green:T.red}`}}/>
|
||||
<span className="mono" style={{fontSize:9,color:T.textSec}}>{state.connected?"CONNECTED":"OFFLINE"}</span>
|
||||
{!isNarrow&&<span className="mono" style={{fontSize:9,color:state.connected?T.green:T.red}}>{state.connected?"CONNECTED":"OFFLINE"}</span>}
|
||||
{/* Undo/Redo */}
|
||||
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:undoStack.length>0?1:0.3}}
|
||||
onClick={handleUndo} disabled={undoStack.length===0} title="Undo (Ctrl+Z)">
|
||||
@@ -800,16 +871,16 @@ export default function App(){
|
||||
{loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}>⟳</span>}
|
||||
</div>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
{!isNarrow&&<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
||||
<VUMeter level={vuLevel} height={24}/>
|
||||
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
|
||||
</div>
|
||||
<span className="mono" style={{fontSize:9,color:T.textSec}}>CPU <span style={{color:T.amber,fontWeight:700}}>{state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span></span>
|
||||
</div>}
|
||||
<div style={{width:1,height:12,background:T.border}}/>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("captures")}title="Downloads">
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_bank.svg`} alt="captures" style={{width:14,height:14}}/>
|
||||
</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets">
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14,filter:'invert(1) brightness(2)'}}/>
|
||||
<img src={`${import.meta.env.BASE_URL}img/ic_presets.svg`} alt="presets" style={{width:14,height:14}}/>
|
||||
</button>
|
||||
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
|
||||
{["stomp","preset"].map(m=>(<button key={m} onClick={()=>setFootswitchMode(m)} style={{
|
||||
@@ -824,7 +895,7 @@ export default function App(){
|
||||
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
|
||||
borderColor:state.tuner_enabled?T.green:T.border}}
|
||||
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
|
||||
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'invert(0.9) brightness(2) sepia(0.4) hue-rotate(80deg)':'invert(1) brightness(2)'}}/>
|
||||
<img src={`${import.meta.env.BASE_URL}img/fx_analyzer.svg`} alt="tuner" style={{width:14,height:14,filter:state.tuner_enabled?'brightness(0) saturate(100%) invert(0.9) sepia(0.4) hue-rotate(80deg)':'none'}}/>
|
||||
</button>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative',background:'rgba(232,160,48,.12)',borderColor:T.amber}}onClick={()=>{if(!document.fullscreenElement){document.documentElement.requestFullscreen()}else{document.exitFullscreen()}}}title="Fullscreen">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="#E8A030">
|
||||
@@ -834,7 +905,7 @@ export default function App(){
|
||||
<div style={{width:1,height:16,background:T.border}}/>
|
||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}
|
||||
onClick={()=>setMenuOpen(true)} title="Main Menu">
|
||||
<span style={{fontSize:14,color:T.textSec,lineHeight:1}}>☰</span>
|
||||
<span style={{fontSize:14,color:T.amber,lineHeight:1}}>☰</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -843,8 +914,22 @@ export default function App(){
|
||||
<div style={{flex:1,display:"flex",overflow:"hidden"}}>
|
||||
|
||||
{/* ─── Preset Sidebar ─── */}
|
||||
<div style={{width:90,flexShrink:0,background:T.surface,borderRight:`1px solid ${T.border}`,
|
||||
display:"flex",flexDirection:"column",overflow:"hidden"}}>
|
||||
{(!isNarrow || sidebarOpen)&&<div style={{
|
||||
width:isNarrow?"100%":90,flexShrink:0,background:T.surface,
|
||||
borderRight:isNarrow?"none":`1px solid ${T.border}`,
|
||||
borderBottom:isNarrow?`1px solid ${T.border}`:"none",
|
||||
display:"flex",flexDirection:"column",overflow:"hidden",
|
||||
position:isNarrow?"fixed":"relative",
|
||||
zIndex:isNarrow?50:"auto",
|
||||
left:0,right:0,
|
||||
top:"auto",
|
||||
bottom:isNarrow?0:"auto",
|
||||
height:isNarrow?140:"100%",
|
||||
borderTopLeftRadius:isNarrow?12:0,
|
||||
borderTopRightRadius:isNarrow?12:0,
|
||||
boxShadow:isNarrow?"0 -4px 24px rgba(0,0,0,.6)":"none",
|
||||
transform:isNarrow&&sidebarOpen?"translateY(0)":"none",
|
||||
}}>
|
||||
{/* Bank header */}
|
||||
<div style={{padding:"6px 8px",textAlign:"center",borderBottom:`1px solid ${T.border}`,background:T.panel}}>
|
||||
<div className="mono" style={{fontSize:14,fontWeight:700,color:T.amber}}>Bank {bankLetter}</div>
|
||||
@@ -862,7 +947,10 @@ export default function App(){
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
{/* Backdrop for narrow sidebar */}
|
||||
{isNarrow&&sidebarOpen&&<div style={{position:"fixed",inset:0,zIndex:49,background:"rgba(0,0,0,.5)"}}
|
||||
onClick={()=>setSidebarOpen(false)}/>}
|
||||
|
||||
{/* ─── Main Content Area ─── */}
|
||||
<div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}>
|
||||
@@ -893,6 +981,7 @@ export default function App(){
|
||||
onAddBlock={handleAddBlock}
|
||||
onRemoveBlock={handleRemoveBlock}
|
||||
onModelBrowser={handleOpenModelBrowser}
|
||||
onBlockSelected={handleAddBlockType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -955,7 +1044,7 @@ export default function App(){
|
||||
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
|
||||
{view==="presets"&&(
|
||||
<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
|
||||
<div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:28,height:28,fontSize:13}}onClick={()=>setView("main")}>✕</button></div>
|
||||
<div className="screen-header"><div className="screen-title">Presets</div><button className="btn-icon"style={{width:36,height:36,fontSize:13}}onClick={()=>setView("main")}>✕</button></div>
|
||||
<div className="screen-body">
|
||||
{presets.map((p,i)=>(
|
||||
<div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
|
||||
@@ -1012,6 +1101,8 @@ export default function App(){
|
||||
onClose={()=>setSettingsView(null)}
|
||||
onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})}
|
||||
onAudioProfile={(rate, period)=>API.setAudioProfile(rate, period).catch(()=>{})}
|
||||
onRoutingChange={(mode, bp)=>API.setRouting(mode, bp).catch(()=>{})}
|
||||
onSaveSettings={(s)=>API.saveSettings(s).catch(()=>{})}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1022,6 +1113,133 @@ export default function App(){
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Save Preset overlay ── */}
|
||||
{overlayView==="save"&&(
|
||||
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||||
<div className="card" style={{width:320,maxWidth:"85vw",padding:20}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
|
||||
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>💾 Save Preset</div>
|
||||
<button className="btn-icon" style={{width:26,height:26,fontSize:12}}onClick={()=>setOverlayView(null)}>✕</button>
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:12}}>
|
||||
<div>
|
||||
<div style={{fontSize:9,color:T.textSec,marginBottom:4,textTransform:"uppercase",letterSpacing:".06em"}}>Preset Name</div>
|
||||
<input defaultValue={presets.find(p=>p.num===currentPreset)?.name||""}
|
||||
style={{width:"100%",padding:"8px 10px",borderRadius:6,border:`1px solid ${T.border}`,background:T.surface,color:T.textPrimary,fontSize:13,outline:"none"}}/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{fontSize:9,color:T.textSec,marginBottom:4,textTransform:"uppercase",letterSpacing:".06em"}}>Target</div>
|
||||
<div style={{display:"flex",gap:4,flexWrap:"wrap"}}>
|
||||
{[1,2,3,4].map(slot=>(
|
||||
<button key={slot} className="btn-ghost" style={{flex:1,padding:"6px",fontSize:10,textAlign:"center"}}
|
||||
onClick={()=>{setOverlayView(null);API.savePreset(currentBank-1,slot-1,"Saved Preset",[]).catch(()=>{});}}>
|
||||
Slot {slot}<br/>
|
||||
<span style={{fontSize:8,color:T.textDim}}>{presets.find(p=>p.num===slot)?.name||"Empty"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Bypass/Control overlay ── */}
|
||||
{overlayView==="bypass"&&(
|
||||
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||||
<div className="card" style={{width:340,maxWidth:"85vw",padding:20}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
|
||||
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>🔌 Bypass / Control</div>
|
||||
<button className="btn-icon" style={{width:26,height:26,fontSize:12}}onClick={()=>setOverlayView(null)}>✕</button>
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:14}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
|
||||
<span style={{fontSize:12,color:T.textPrimary}}>🚫 Global Bypass</span>
|
||||
<button className="btn-icon" style={{width:40,height:26,fontSize:10,background:globalBypass?`${T.red}22`:T.surface,borderColor:globalBypass?T.red:T.border}}
|
||||
onClick={()=>{setGlobalBypass(p=>!p);API.bypassToggle().catch(()=>{});}}>
|
||||
{globalBypass?"ON":"OFF"}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{height:1,background:T.border}}/>
|
||||
<div>
|
||||
<div style={{fontSize:9,color:T.textSec,marginBottom:6,textTransform:"uppercase",letterSpacing:".06em"}}>Block Bypass States</div>
|
||||
{blocks.map(b=>(
|
||||
<div key={b.id} style={{display:"flex",justifyContent:"space-between",alignItems:"center",padding:"4px 0"}}>
|
||||
<span style={{fontSize:11,color:T.textPrimary}}>{b.name||b.type}</span>
|
||||
<button className="btn-icon" style={{width:36,height:22,fontSize:9,background:b.bypassed?`${T.red}22`:T.surface,borderColor:b.bypassed?T.red:T.border}}
|
||||
onClick={()=>handleToggleBlock(b.id)}>
|
||||
{b.bypassed?"BYP":"ACT"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{height:1,background:T.border}}/>
|
||||
<div>
|
||||
<div style={{fontSize:9,color:T.textSec,marginBottom:6,textTransform:"uppercase",letterSpacing:".06em"}}>Footswitch Mode</div>
|
||||
<div style={{display:"flex",gap:4,background:"#0A0A10",borderRadius:6,padding:4}}>
|
||||
{["stomp","preset"].map(m=>(
|
||||
<button key={m} onClick={()=>setFootswitchMode(m)} style={{
|
||||
flex:1,padding:"5px 8px",borderRadius:4,border:"none",fontSize:10,fontWeight:600,
|
||||
cursor:"pointer",background:footswitchMode===m?T.amber:"transparent",
|
||||
color:footswitchMode===m?"#000":T.textSec,
|
||||
}}>{m==="stomp"?"Stompbox":"Preset"}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Wi-Fi / Bluetooth overlay ── */}
|
||||
{overlayView==="wifi"&&(
|
||||
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}>
|
||||
<div className="card" style={{width:340,maxWidth:"85vw",padding:20}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
|
||||
<div style={{fontSize:14,fontWeight:600,color:T.textPrimary}}>📶 Wireless</div>
|
||||
<button className="btn-icon" style={{width:26,height:26,fontSize:12}}onClick={()=>setOverlayView(null)}>✕</button>
|
||||
</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:14}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
|
||||
<span style={{fontSize:12,color:T.textPrimary}}>📶 Wi‑Fi</span>
|
||||
<button className="btn-icon" style={{width:40,height:26,fontSize:10}}>Scan</button>
|
||||
</div>
|
||||
<div className="card-sm" style={{textAlign:"center",padding:16}}>
|
||||
<span style={{fontSize:11,color:T.textSec}}>Use pedal as access point or connect to existing network via the settings panel.</span>
|
||||
</div>
|
||||
<div style={{height:1,background:T.border}}/>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center"}}>
|
||||
<span style={{fontSize:12,color:T.textPrimary}}>🔵 Bluetooth</span>
|
||||
<button className="btn-icon" style={{width:40,height:26,fontSize:10,background:`${T.blue}22`,borderColor:T.blue,color:T.blue}}>ON</button>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:T.textSec}}>Bluetooth MIDI is enabled. Pair with your mobile device or MIDI controller.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Block Type Picker ── */}
|
||||
{addBlockType==="picker"&&(
|
||||
<div style={{position:"fixed",inset:0,zIndex:1000,background:"rgba(0,0,0,.7)",display:"flex",alignItems:"center",justifyContent:"center"}}
|
||||
onClick={(e)=>e.target===e.currentTarget&&setAddBlockType(null)}>
|
||||
<div className="card" style={{width:320,maxWidth:"85vw",padding:20,maxHeight:"80vh",overflowY:"auto"}}>
|
||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}}>
|
||||
<div style={{fontSize:13,fontWeight:600,color:T.textPrimary}}>+ Add Block</div>
|
||||
<button className="btn-icon" style={{width:24,height:24,fontSize:11}}onClick={()=>setAddBlockType(null)}>✕</button>
|
||||
</div>
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:6}}>
|
||||
{Object.entries(BLOCK_DISPLAY_NAMES).filter(([k])=>!["loop","split","merge"].includes(k)).map(([type,name])=>(
|
||||
<button key={type} className="btn-ghost" style={{padding:"8px 6px",fontSize:10,textAlign:"center",lineHeight:1.3}}
|
||||
onClick={()=>handleAddBlockType(type)}>
|
||||
<div style={{fontWeight:600}}>{name}</div>
|
||||
<div style={{fontSize:8,color:T.textDim,marginTop:1,textTransform:"capitalize"}}>{type}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div></>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ const T = {
|
||||
border: "#2A2A32",
|
||||
amber: "#E8A030",
|
||||
amberDim: "#7A5218",
|
||||
blue: "#3A7BA8",
|
||||
blue: "#4A8BC8",
|
||||
blueDim: "#1E4060",
|
||||
green: "#3AB87A",
|
||||
red: "#C84040",
|
||||
red: "#E06060",
|
||||
textPrimary: "#F0EDE6",
|
||||
textSec: "#8888A0",
|
||||
textDim: "#444458",
|
||||
textSec: "#A0A0BC",
|
||||
textDim: "#7878A0",
|
||||
};
|
||||
|
||||
// ── Split type metadata ────────────────────────────
|
||||
@@ -95,7 +95,7 @@ function BlockIcon({ type, size = 14, style = {} }) {
|
||||
alt={type || 'fx'}
|
||||
style={{
|
||||
width: size, height: size, flexShrink: 0,
|
||||
filter: 'invert(0.95) brightness(2)',
|
||||
filter: 'none',
|
||||
objectFit: 'contain',
|
||||
...style,
|
||||
}}
|
||||
@@ -865,8 +865,8 @@ export default function BlockChain({
|
||||
</button>
|
||||
{selectedBlockId && (
|
||||
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{
|
||||
padding: "10px 14px", borderRadius: 7, background: "rgba(200,64,64,.15)",
|
||||
border: `1px solid rgba(200,64,64,.3)`, color: T.red, fontSize: 12,
|
||||
padding: "10px 14px", borderRadius: 7, background: "rgba(224,96,96,.15)",
|
||||
border: `1px solid rgba(224,96,96,.3)`, color: T.red, fontSize: 12,
|
||||
fontWeight: 600, cursor: "pointer", transition: "all .12s",
|
||||
}}>
|
||||
✕ Remove
|
||||
|
||||
@@ -8,13 +8,13 @@ const T = {
|
||||
border: "#2A2A32",
|
||||
amber: "#E8A030",
|
||||
amberDim: "#7A5218",
|
||||
blue: "#3A7BA8",
|
||||
blue: "#4A8BC8",
|
||||
blueDim: "#1E4060",
|
||||
green: "#3AB87A",
|
||||
red: "#C84040",
|
||||
red: "#E06060",
|
||||
textPrimary: "#F0EDE6",
|
||||
textSec: "#8888A0",
|
||||
textDim: "#444458",
|
||||
textSec: "#A0A0BC",
|
||||
textDim: "#7878A0",
|
||||
};
|
||||
|
||||
// ── Block param definitions (mirrored from App.jsx) ──
|
||||
@@ -338,7 +338,7 @@ export default function FocusView({
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: T.bg,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontFamily: "'Space Grotesk', sans-serif",
|
||||
color: T.textPrimary,
|
||||
}}>
|
||||
{/* ── Header ── */}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useRef, useEffect } from "react";
|
||||
|
||||
const T = {
|
||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||
};
|
||||
|
||||
const TYPE_COLORS = {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useRef, useCallback, useEffect } from "react";
|
||||
|
||||
const T = {
|
||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||
};
|
||||
|
||||
// ── EQ Band definitions ──
|
||||
@@ -265,7 +265,7 @@ export default function GlobalEQ({ onClose }) {
|
||||
position: "fixed", inset: 0, zIndex: 998,
|
||||
display: "flex", flexDirection: "column",
|
||||
background: T.bg, color: T.textPrimary,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontFamily: "'Space Grotesk', sans-serif",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
|
||||
const T = {
|
||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||
};
|
||||
|
||||
// ── Settings definitions ──
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: { "Content-Type": "application/json" } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(`${path}`, opts);
|
||||
if (!res.ok) throw new Error(`API ${method} ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const SETTINGS = {
|
||||
"Audio": [
|
||||
{ key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊" },
|
||||
{ key: "master_volume", label: "Master Volume", type: "slider", min: 0, max: 100, def: 72, unit: "%", icon: "🔊",
|
||||
via: "volume" },
|
||||
{ key: "sample_rate", label: "Sample Rate", type: "select", def: "48000",
|
||||
options: [
|
||||
{ value: "22050", label: "22.05 kHz" },
|
||||
@@ -17,7 +25,7 @@ const SETTINGS = {
|
||||
{ value: "48000", label: "48 kHz" },
|
||||
{ value: "88200", label: "88.2 kHz" },
|
||||
{ value: "96000", label: "96 kHz" },
|
||||
], icon: "📊" },
|
||||
], icon: "📊", via: "audio_profile" },
|
||||
{ key: "buffer_size", label: "Buffer Size", type: "select", def: "512",
|
||||
options: [
|
||||
{ value: "64", label: "64 (1.3ms)" },
|
||||
@@ -25,14 +33,14 @@ const SETTINGS = {
|
||||
{ value: "256", label: "256 (5.3ms)" },
|
||||
{ value: "512", label: "512 (10.6ms)" },
|
||||
{ value: "1024",label: "1024 (21.3ms)" },
|
||||
], icon: "📦" },
|
||||
], icon: "📦", via: "audio_profile" },
|
||||
{ key: "input_pad", label: "Input Pad", type: "select", def: "off",
|
||||
options: [
|
||||
{ value: "off", label: "Off (0 dB)" },
|
||||
{ value: "-6", label: "-6 dB" },
|
||||
{ value: "-12", label: "-12 dB" },
|
||||
{ value: "-18", label: "-18 dB" },
|
||||
], icon: "📥" },
|
||||
], icon: "📥", via: "settings" },
|
||||
{ key: "input_impedance", label: "Input Impedance", type: "select", def: "1m",
|
||||
options: [
|
||||
{ value: "1m", label: "1 MΩ" },
|
||||
@@ -40,7 +48,45 @@ const SETTINGS = {
|
||||
{ value: "250k", label: "250 kΩ" },
|
||||
{ value: "150k", label: "150 kΩ" },
|
||||
{ value: "68k", label: "68 kΩ" },
|
||||
], icon: "⚡" },
|
||||
], icon: "⚡", via: "settings" },
|
||||
],
|
||||
"Routing": [
|
||||
{ key: "routing_mode", label: "Routing Mode", type: "select", def: "mono",
|
||||
options: [
|
||||
{ value: "mono", label: "Mono" },
|
||||
{ value: "4cm", label: "4CM (Stereo FX Loop)" },
|
||||
], icon: "🔀", via: "routing" },
|
||||
{ key: "routing_breakpoint", label: "FX Loop Breakpoint", type: "select", def: "7",
|
||||
options: [
|
||||
{ value: "1", label: "After block 1" },
|
||||
{ value: "2", label: "After block 2" },
|
||||
{ value: "3", label: "After block 3" },
|
||||
{ value: "4", label: "After block 4" },
|
||||
{ value: "5", label: "After block 5" },
|
||||
{ value: "6", label: "After block 6" },
|
||||
{ value: "7", label: "After block 7" },
|
||||
{ value: "8", label: "After block 8" },
|
||||
], icon: "↔", via: "routing" },
|
||||
],
|
||||
"Audio Interface": [
|
||||
{ key: "input_device", label: "Input Device", type: "select", def: "hw:0,0",
|
||||
options: [
|
||||
{ value: "hw:0,0", label: "Default (hw:0,0)" },
|
||||
{ value: "hw:1,0", label: "USB Audio (hw:1,0)" },
|
||||
{ value: "hw:2,0", label: "HDMI Audio (hw:2,0)" },
|
||||
], icon: "🎤", via: "settings" },
|
||||
{ key: "output_device", label: "Output Device", type: "select", def: "hw:0,0",
|
||||
options: [
|
||||
{ value: "hw:0,0", label: "Default (hw:0,0)" },
|
||||
{ value: "hw:1,0", label: "USB Audio (hw:1,0)" },
|
||||
{ value: "hw:2,0", label: "HDMI Audio (hw:2,0)" },
|
||||
], icon: "🔈", via: "settings" },
|
||||
{ key: "channel_mode", label: "Channel Mode", type: "select", def: "mono",
|
||||
options: [
|
||||
{ value: "mono", label: "Mono" },
|
||||
{ value: "dual-mono", label: "Dual Mono" },
|
||||
{ value: "stereo_4cm", label: "Stereo (4CM)" },
|
||||
], icon: "🔊", via: "settings" },
|
||||
],
|
||||
"MIDI": [
|
||||
{ key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni",
|
||||
@@ -49,13 +95,13 @@ const SETTINGS = {
|
||||
...Array.from({ length: 16 }, (_, i) => ({
|
||||
value: String(i + 1), label: `Channel ${i + 1}`
|
||||
})),
|
||||
], icon: "🎹" },
|
||||
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, icon: "⏱" },
|
||||
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, icon: "↔" },
|
||||
], icon: "🎹", via: "settings" },
|
||||
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, via: "settings", icon: "⏱" },
|
||||
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, via: "settings", icon: "↔" },
|
||||
],
|
||||
"Display": [
|
||||
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️" },
|
||||
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, icon: "🌙" },
|
||||
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", icon: "☀️", via: "settings" },
|
||||
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, via: "settings", icon: "🌙" },
|
||||
{ key: "screen_saver", label: "Screen Saver", type: "select", def: "5m",
|
||||
options: [
|
||||
{ value: "off", label: "Off" },
|
||||
@@ -63,10 +109,10 @@ const SETTINGS = {
|
||||
{ value: "5m", label: "5 min" },
|
||||
{ value: "15m", label: "15 min" },
|
||||
{ value: "30m", label: "30 min" },
|
||||
], icon: "💤" },
|
||||
], icon: "💤", via: "settings" },
|
||||
],
|
||||
"Tempo": [
|
||||
{ key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, icon: "🔄" },
|
||||
{ key: "tap_tempo", label: "Tap Tempo", type: "bpm", def: 120, via: "settings", icon: "🔄" },
|
||||
{ key: "tempo_division", label: "Tempo Division", type: "select", def: "quarter",
|
||||
options: [
|
||||
{ value: "half", label: "½ Note" },
|
||||
@@ -75,12 +121,23 @@ const SETTINGS = {
|
||||
{ value: "triplet", label: "⅛ Triplet" },
|
||||
{ value: "sixteenth", label: "¹⁄₁₆ Note" },
|
||||
{ value: "dotted_eighth", label: "Dotted ⅛" },
|
||||
], icon: "🎶" },
|
||||
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, icon: "🔇" },
|
||||
], icon: "🎶", via: "settings" },
|
||||
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, via: "settings", icon: "🔇" },
|
||||
],
|
||||
};
|
||||
|
||||
// ── Slider Widget ──
|
||||
function buildDefaults(overrides = {}) {
|
||||
const init = {};
|
||||
for (const group of Object.values(SETTINGS)) {
|
||||
for (const s of group) {
|
||||
init[s.key] = s.def;
|
||||
}
|
||||
}
|
||||
return { ...init, ...overrides };
|
||||
}
|
||||
|
||||
// ── Widgets ────────────────────────────────────────────────────────────────
|
||||
|
||||
function SettingsSlider({ label, value, min, max, unit, onChange }) {
|
||||
const trackRef = useRef(null);
|
||||
const dragRef = useRef(false);
|
||||
@@ -138,45 +195,22 @@ function SettingsSlider({ label, value, min, max, unit, onChange }) {
|
||||
position: "relative", height: 32, display: "flex", alignItems: "center",
|
||||
cursor: "pointer", touchAction: "none",
|
||||
}}>
|
||||
<div style={{
|
||||
width: "100%", height: 6, background: T.border, borderRadius: 3,
|
||||
position: "relative", overflow: "hidden",
|
||||
}}>
|
||||
<div style={{
|
||||
height: "100%", borderRadius: 3, position: "absolute", top: 0, left: 0,
|
||||
width: `${pct}%`, background: T.amber,
|
||||
boxShadow: `0 0 6px ${T.amber}44`,
|
||||
transition: "width .03s",
|
||||
}} />
|
||||
<div style={{ width: "100%", height: 6, background: T.border, borderRadius: 3, position: "relative", overflow: "hidden" }}>
|
||||
<div style={{ height: "100%", borderRadius: 3, position: "absolute", top: 0, left: 0, width: `${pct}%`, background: T.amber, boxShadow: `0 0 6px ${T.amber}44`, transition: "width .03s" }} />
|
||||
</div>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: "50%", position: "absolute",
|
||||
top: "50%", left: `${pct}%`,
|
||||
transform: "translate(-50%,-50%)",
|
||||
background: T.amber, border: `3px solid ${T.bg}`,
|
||||
boxShadow: `0 1px 6px rgba(0,0,0,.5)`,
|
||||
pointerEvents: "none",
|
||||
transition: "left .03s",
|
||||
}} />
|
||||
<div style={{ width: 24, height: 24, borderRadius: "50%", position: "absolute", top: "50%", left: `${pct}%`, transform: "translate(-50%,-50%)", background: T.amber, border: `3px solid ${T.bg}`, boxShadow: `0 1px 6px rgba(0,0,0,.5)`, pointerEvents: "none", transition: "left .03s" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Select Widget ──
|
||||
function SettingsSelect({ label, value, options, onChange, icon }) {
|
||||
function SettingsSelect({ label, value, options, onChange }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", gap: 4, flexWrap: "wrap",
|
||||
background: T.panel, borderRadius: 6, padding: 4,
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", background: T.panel, borderRadius: 6, padding: 4 }}>
|
||||
{options.map(opt => (
|
||||
<button key={opt.value}
|
||||
onClick={() => onChange?.(opt.value)}
|
||||
@@ -186,25 +220,18 @@ function SettingsSelect({ label, value, options, onChange, icon }) {
|
||||
background: value === opt.value ? T.amber : "transparent",
|
||||
color: value === opt.value ? "#000" : T.textSec,
|
||||
transition: "all .1s", letterSpacing: ".03em",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
whiteSpace: "nowrap", minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
>{opt.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toggle Widget ──
|
||||
function SettingsToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
padding: "4px 0",
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 0" }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
<button
|
||||
onClick={() => onChange?.(!value)}
|
||||
@@ -227,7 +254,6 @@ function SettingsToggle({ label, value, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── BPM/Tap Tempo Widget ──
|
||||
function BpmWidget({ label, value, onChange }) {
|
||||
const tapTimes = useRef([]);
|
||||
|
||||
@@ -247,178 +273,177 @@ function BpmWidget({ label, value, onChange }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: "flex", gap: 8, alignItems: "center",
|
||||
}}>
|
||||
<button onClick={handleTap}
|
||||
style={{
|
||||
padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`,
|
||||
background: `${T.amber}15`, color: T.amber,
|
||||
fontSize: 12, fontWeight: 700, cursor: "pointer",
|
||||
letterSpacing: ".05em",
|
||||
}}>
|
||||
TAP
|
||||
</button>
|
||||
<div style={{
|
||||
flex: 1, display: "flex", alignItems: "center", gap: 4,
|
||||
background: T.surface, borderRadius: 6, border: `1px solid ${T.border}`,
|
||||
padding: "6px 10px",
|
||||
}}>
|
||||
<button onClick={() => onChange?.(Math.max(20, value - 1))}
|
||||
style={{
|
||||
background: "none", border: "none", color: T.textDim,
|
||||
fontSize: 14, cursor: "pointer", padding: "0 4px",
|
||||
}}>−</button>
|
||||
<span style={{
|
||||
flex: 1, textAlign: "center",
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber,
|
||||
}}>{value} BPM</span>
|
||||
<button onClick={() => onChange?.(Math.min(300, value + 1))}
|
||||
style={{
|
||||
background: "none", border: "none", color: T.textDim,
|
||||
fontSize: 14, cursor: "pointer", padding: "0 4px",
|
||||
}}>+</button>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button onClick={handleTap} style={{ padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`, background: `${T.amber}15`, color: T.amber, fontSize: 12, fontWeight: 700, cursor: "pointer", letterSpacing: ".05em" }}>TAP</button>
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 4, background: T.surface, borderRadius: 6, border: `1px solid ${T.border}`, padding: "6px 10px" }}>
|
||||
<button onClick={() => onChange?.(Math.max(20, value - 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}>−</button>
|
||||
<span style={{ flex: 1, textAlign: "center", fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber }}>{value} BPM</span>
|
||||
<button onClick={() => onChange?.(Math.min(300, value + 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Global Settings ──
|
||||
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile }) {
|
||||
const [values, setValues] = useState(() => {
|
||||
const init = {};
|
||||
for (const group of Object.values(SETTINGS)) {
|
||||
for (const s of group) {
|
||||
init[s.key] = s.def;
|
||||
// ── Global Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings }) {
|
||||
const [values, setValues] = useState(buildDefaults());
|
||||
const [savedValues, setSavedValues] = useState(buildDefaults());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState(null); // "saved" | "error" | null
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
api("GET", "/api/state").catch(() => null),
|
||||
api("GET", "/api/audio/profile").catch(() => null),
|
||||
api("GET", "/api/routing").catch(() => null),
|
||||
]).then(([state, audioProfile, routing]) => {
|
||||
if (cancelled) return;
|
||||
const overrides = {};
|
||||
if (state) {
|
||||
if (state.master_volume != null) overrides.master_volume = Math.round(state.master_volume * 100);
|
||||
if (state.sample_rate != null) overrides.sample_rate = String(state.sample_rate);
|
||||
if (state.channel_mode) overrides.channel_mode = state.channel_mode;
|
||||
}
|
||||
}
|
||||
return init;
|
||||
});
|
||||
if (audioProfile) {
|
||||
if (audioProfile.rate != null) overrides.sample_rate = String(audioProfile.rate);
|
||||
if (audioProfile.period != null) overrides.buffer_size = String(audioProfile.period);
|
||||
}
|
||||
if (routing) {
|
||||
if (routing.routing_mode) overrides.routing_mode = routing.routing_mode;
|
||||
if (routing.routing_breakpoint != null) overrides.routing_breakpoint = String(routing.routing_breakpoint);
|
||||
}
|
||||
const merged = { ...buildDefaults(), ...overrides };
|
||||
setValues(merged);
|
||||
setSavedValues(merged);
|
||||
setLoaded(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
return Object.keys(values).some(k => values[k] !== savedValues[k]);
|
||||
}, [values, savedValues]);
|
||||
|
||||
const groups = Object.entries(SETTINGS);
|
||||
|
||||
const update = useCallback((key, val) => {
|
||||
setValues(prev => ({ ...prev, [key]: val }));
|
||||
// Fire external callback for master volume
|
||||
if (key === "master_volume") {
|
||||
onMasterVolume?.(val);
|
||||
setSaveMsg(null);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
const changed = {};
|
||||
for (const k of Object.keys(values)) {
|
||||
if (values[k] !== savedValues[k]) {
|
||||
changed[k] = values[k];
|
||||
}
|
||||
}
|
||||
// Fire external callback for audio profile changes
|
||||
if (key === "sample_rate" || key === "buffer_size") {
|
||||
// Get the latest values — use the new val for the changed key
|
||||
setValues(prev => {
|
||||
const rate = key === "sample_rate" ? val : (prev.sample_rate || "48000");
|
||||
const period = key === "buffer_size" ? val : (prev.buffer_size || "512");
|
||||
onAudioProfile?.(parseInt(rate), parseInt(period));
|
||||
return prev;
|
||||
});
|
||||
// Apply instant-effect settings via dedicated endpoints first
|
||||
const promises = [];
|
||||
if (changed.master_volume != null) {
|
||||
onMasterVolume?.(changed.master_volume);
|
||||
}
|
||||
}, [onMasterVolume, onAudioProfile]);
|
||||
if (changed.sample_rate != null || changed.buffer_size != null) {
|
||||
const rate = changed.sample_rate || values.sample_rate || "48000";
|
||||
const period = changed.buffer_size || values.buffer_size || "512";
|
||||
onAudioProfile?.(parseInt(rate), parseInt(period));
|
||||
}
|
||||
if (changed.routing_mode != null || changed.routing_breakpoint != null) {
|
||||
const mode = changed.routing_mode || values.routing_mode || "mono";
|
||||
const bp = changed.routing_breakpoint || values.routing_breakpoint || "7";
|
||||
onRoutingChange?.(mode, parseInt(bp));
|
||||
}
|
||||
|
||||
// Everything else → bulk POST /api/settings
|
||||
const settingsChanged = Object.fromEntries(
|
||||
Object.entries(changed).filter(([k]) =>
|
||||
!["master_volume", "sample_rate", "buffer_size", "routing_mode", "routing_breakpoint"].includes(k)
|
||||
)
|
||||
);
|
||||
if (Object.keys(settingsChanged).length > 0) {
|
||||
promises.push(
|
||||
api("POST", "/api/settings", settingsChanged)
|
||||
.then(() => {})
|
||||
.catch(() => { throw new Error("Save failed"); })
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
setSavedValues({ ...values });
|
||||
setSaveMsg("saved");
|
||||
setTimeout(() => { setSaveMsg(null); }, 2000);
|
||||
} catch {
|
||||
setSaveMsg("error");
|
||||
}
|
||||
setSaving(false);
|
||||
}, [values, savedValues, onMasterVolume, onAudioProfile, onRoutingChange]);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
setValues({ ...savedValues });
|
||||
setSaveMsg(null);
|
||||
}, [savedValues]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: "fixed", inset: 0, zIndex: 998,
|
||||
display: "flex", flexDirection: "column",
|
||||
background: T.bg, color: T.textPrimary,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "10px 14px",
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
background: T.panel, flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${T.border}`, background: T.panel, flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<button onClick={onClose}
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: 8,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.surface, border: `1px solid ${T.border}`,
|
||||
color: T.textPrimary, fontSize: 16, cursor: "pointer",
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
<button onClick={onClose} style={{ width: 34, height: 34, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", background: T.surface, border: `1px solid ${T.border}`, color: T.textPrimary, fontSize: 16, cursor: "pointer" }}>✕</button>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Global Settings</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Settings</div>
|
||||
{!loaded && <span style={{ fontSize: 10, color: T.textDim }}>Loading…</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{saveMsg === "saved" && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: T.green }}>✓ Saved</span>
|
||||
)}
|
||||
{saveMsg === "error" && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: T.red }}>✗ Save failed</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<span style={{ fontSize: 10, color: T.amber, fontWeight: 600 }}>Unsaved</span>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<button onClick={handleDiscard}
|
||||
style={{ padding: "6px 12px", borderRadius: 6, border: `1px solid ${T.border}`, background: T.surface, color: T.textSec, fontSize: 11, fontWeight: 600, cursor: "pointer" }}>
|
||||
Discard
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSave} disabled={!hasChanges || saving}
|
||||
style={{
|
||||
padding: "6px 14px", borderRadius: 6, border: "none",
|
||||
background: hasChanges ? T.amber : T.border, color: hasChanges ? "#000" : T.textDim,
|
||||
fontSize: 11, fontWeight: 700, cursor: hasChanges ? "pointer" : "default",
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{
|
||||
flex: 1, overflowY: "auto", overflowX: "hidden",
|
||||
padding: "6px 14px 20px",
|
||||
}}>
|
||||
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: "6px 14px 20px" }}>
|
||||
{groups.map(([groupName, settings]) => (
|
||||
<div key={groupName}>
|
||||
{/* Group header */}
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: ".1em",
|
||||
textTransform: "uppercase", color: T.textDim,
|
||||
padding: "14px 0 8px",
|
||||
}}>
|
||||
{groupName}
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", gap: 12,
|
||||
background: T.surface, borderRadius: 8,
|
||||
border: `1px solid ${T.border}`,
|
||||
padding: "12px 14px",
|
||||
}}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", color: T.textDim, padding: "14px 0 8px" }}>{groupName}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12, background: T.surface, borderRadius: 8, border: `1px solid ${T.border}`, padding: "12px 14px" }}>
|
||||
{settings.map(s => (
|
||||
<div key={s.key} style={{
|
||||
display: "flex", alignItems: "flex-start", gap: 10,
|
||||
}}>
|
||||
{/* Icon */}
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 6, flexShrink: 0,
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
background: T.panel, fontSize: 13,
|
||||
}}>
|
||||
{s.icon}
|
||||
</div>
|
||||
{/* Control */}
|
||||
<div key={s.key} style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
|
||||
<div style={{ width: 28, height: 28, borderRadius: 6, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: T.panel, fontSize: 13 }}>{s.icon}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{s.type === "slider" && (
|
||||
<SettingsSlider
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
min={s.min} max={s.max} unit={s.unit}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "select" && (
|
||||
<SettingsSelect
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
options={s.options}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "toggle" && (
|
||||
<SettingsToggle
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "bpm" && (
|
||||
<BpmWidget
|
||||
label={s.label}
|
||||
value={values[s.key] ?? s.def}
|
||||
onChange={v => update(s.key, v)}
|
||||
/>
|
||||
)}
|
||||
{s.type === "slider" && <SettingsSlider label={s.label} value={values[s.key] ?? s.def} min={s.min} max={s.max} unit={s.unit} onChange={v => update(s.key, v)} />}
|
||||
{s.type === "select" && <SettingsSelect label={s.label} value={values[s.key] ?? s.def} options={s.options} onChange={v => update(s.key, v)} />}
|
||||
{s.type === "toggle" && <SettingsToggle label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
|
||||
{s.type === "bpm" && <BpmWidget label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useRef, useEffect } from "react";
|
||||
|
||||
const T = {
|
||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||
};
|
||||
|
||||
const MENU_ITEMS = [
|
||||
@@ -16,12 +16,13 @@ const MENU_ITEMS = [
|
||||
];
|
||||
|
||||
export default function MainMenu({ onClose, onItemSelect }) {
|
||||
const overlayRef = useRef(null);
|
||||
const sidebarRef = useRef(null);
|
||||
const backdropRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = overlayRef.current;
|
||||
const el = sidebarRef.current;
|
||||
if (!el) return;
|
||||
// Trigger enter animation
|
||||
// Trigger enter animation — slide sidebar into view
|
||||
requestAnimationFrame(() => { el.style.transform = "translateX(0)"; });
|
||||
}, []);
|
||||
|
||||
@@ -31,7 +32,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
ref={backdropRef}
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: "fixed", inset: 0, zIndex: 999,
|
||||
@@ -42,6 +43,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
|
||||
>
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
style={{
|
||||
width: 260, maxWidth: "75vw", height: "100%",
|
||||
background: T.panel,
|
||||
|
||||
@@ -8,13 +8,13 @@ const T = {
|
||||
border: "#2A2A32",
|
||||
amber: "#E8A030",
|
||||
amberDim: "#7A5218",
|
||||
blue: "#3A7BA8",
|
||||
blue: "#4A8BC8",
|
||||
blueDim: "#1E4060",
|
||||
green: "#3AB87A",
|
||||
red: "#C84040",
|
||||
red: "#E06060",
|
||||
textPrimary: "#F0EDE6",
|
||||
textSec: "#8888A0",
|
||||
textDim: "#444458",
|
||||
textSec: "#A0A0BC",
|
||||
textDim: "#7878A0",
|
||||
};
|
||||
|
||||
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
|
||||
@@ -365,7 +365,7 @@ export default function ModelBrowser({
|
||||
display: "flex", flexDirection: "column",
|
||||
background: T.bg,
|
||||
color: T.textPrimary,
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontFamily: "'Space Grotesk', sans-serif",
|
||||
}}>
|
||||
{/* ── Header ── */}
|
||||
<div style={{
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
// ── Design tokens (mirrored) ──────────────────────────────────
|
||||
const T = {
|
||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8",
|
||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6",
|
||||
textSec: "#8888A0", textDim: "#444458",
|
||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8",
|
||||
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6",
|
||||
textSec: "#A0A0BC", textDim: "#7878A0",
|
||||
};
|
||||
|
||||
// ── String labels matching _STRING_NAMES ──────────────────────
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
/* ── CSS custom properties (design tokens) ── */
|
||||
:root {
|
||||
--bg: #0A0A0C;
|
||||
--panel: #141418;
|
||||
--surface: #1C1C22;
|
||||
--border: #2A2A32;
|
||||
--amber: #E8A030;
|
||||
--amber-dim: #7A5218;
|
||||
--blue: #4A8BC8;
|
||||
--blue-dim: #1E4060;
|
||||
--green: #3AB87A;
|
||||
--red: #E06060;
|
||||
--text-primary: #F0EDE6;
|
||||
--text-sec: #A0A0BC;
|
||||
--text-dim: #7878A0;
|
||||
}
|
||||
|
||||
/* ── Reset & base ── */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; overflow: hidden; }
|
||||
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
body { background: #0A0A0C; color: #F0EDE6; font-family: 'Space Grotesk', sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
|
||||
/* ── Utility classes (from pipedal) ── */
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
@@ -12,8 +29,8 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
|
||||
letter-spacing: .06em; text-transform: uppercase; }
|
||||
.badge-amber { background: rgba(232,160,48,.2); color: #E8A030; }
|
||||
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
|
||||
.badge-blue { background: rgba(58,123,168,.2); color: #3A7BA8; }
|
||||
.badge-red { background: rgba(200,64,64,.2); color: #C84040; }
|
||||
.badge-blue { background: rgba(74,139,200,.2); color: #4A8BC8; }
|
||||
.badge-red { background: rgba(224,96,96,.2); color: #E06060; }
|
||||
|
||||
/* Button system */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
@@ -23,7 +40,7 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
|
||||
.btn:active { transform: scale(.96); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 10px; }
|
||||
.btn-ghost { background: transparent; border-color: transparent; }
|
||||
.btn-danger { border-color: #C84040; background: rgba(200,64,64,.15); color: #C84040; }
|
||||
.btn-danger { border-color: #E06060; background: rgba(224,96,96,.15); color: #E06060; }
|
||||
.btn-icon { display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: 5px; border: 1px solid #2A2A32;
|
||||
background: #1C1C22; cursor: pointer; transition: all .12s; touch-action: manipulation; }
|
||||
@@ -72,7 +89,7 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
|
||||
align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
|
||||
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
|
||||
.transport-btn:active { transform: scale(.92); }
|
||||
.transport-btn.rec { border-color: #C84040; background: rgba(200,64,64,.15); }
|
||||
.transport-btn.rec { border-color: #E06060; background: rgba(224,96,96,.15); }
|
||||
.transport-btn.play { border-color: #3AB87A; background: rgba(58,184,122,.15); }
|
||||
|
||||
/* Switch toggle */
|
||||
@@ -112,10 +129,105 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
|
||||
|
||||
/* NAM chip */
|
||||
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
|
||||
background: rgba(58,123,168,.15); border: 1px solid rgba(58,123,168,.35);
|
||||
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #3A7BA8; }
|
||||
background: rgba(74,139,200,.15); border: 1px solid rgba(74,139,200,.35);
|
||||
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #4A8BC8; }
|
||||
|
||||
/* Bypass LED */
|
||||
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.led-on { background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
|
||||
.led-off { background: #444458; }
|
||||
|
||||
/* ── Reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Responsive breakpoints ── */
|
||||
|
||||
/* Portrait phones */
|
||||
@media (max-width: 640px) {
|
||||
.slide-panel-inner {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.screen-header {
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
.screen-body {
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.screen-title {
|
||||
font-size: 11px;
|
||||
}
|
||||
.preset-row {
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.preset-chip-s {
|
||||
padding: 5px 4px;
|
||||
}
|
||||
.preset-chip-s .pcs-num {
|
||||
font-size: 12px;
|
||||
}
|
||||
.preset-chip-s .pcs-name {
|
||||
font-size: 8px;
|
||||
}
|
||||
.setting-row {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.btn-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.card {
|
||||
padding: 10px;
|
||||
}
|
||||
.card-sm {
|
||||
padding: 8px;
|
||||
}
|
||||
.snap-slot {
|
||||
min-width: 50px;
|
||||
padding: 6px 3px;
|
||||
}
|
||||
.snap-num {
|
||||
font-size: 14px;
|
||||
}
|
||||
.mono {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape phone / tablet portrait */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.slide-panel-inner {
|
||||
max-width: 540px;
|
||||
}
|
||||
.screen-header {
|
||||
padding: 10px 12px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape short screens */
|
||||
@media (orientation: landscape) and (max-height: 500px) {
|
||||
.slide-panel-inner {
|
||||
max-height: 92vh;
|
||||
}
|
||||
.screen-header {
|
||||
padding: 6px 12px 5px;
|
||||
}
|
||||
.screen-body {
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||