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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NAM Pedal — Helix Stadium UX Concept</title>
|
<title>NAM Pedal — Helix Stadium UX Concept</title>
|
||||||
<style>
|
<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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-tap-highlight-color: transparent; }
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
<meta charset="UTF-8">
|
<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, user-scalable=no">
|
||||||
<title>Pi Multi-FX — Helix Stadium Mockup</title>
|
<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>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; user-select: none; }
|
* { 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 {
|
.device {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="theme-color" content="#0A0A0C" />
|
||||||
<title>NAM Pedal</title>
|
<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" />
|
<link rel="icon" type="image/svg+xml" href="/ui/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- 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">
|
<!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">
|
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||||
<g id="Layer_1">
|
<g id="Layer_1">
|
||||||
<g>
|
<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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="48px"
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
height="48px" viewBox="0 0 48 48" fill="#F0EDE6">
|
||||||
<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>
|
|
||||||
<g id="Combined">
|
<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
|
<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"/>
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- 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">
|
<!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">
|
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||||
<g id="Layer_1">
|
<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
|
<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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- 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">
|
<!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">
|
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 477 B After Width: | Height: | Size: 492 B |
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- 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">
|
<!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">
|
height="48px" viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
|
||||||
<g id="Layer_2">
|
<g id="Layer_2">
|
||||||
<g>
|
<g>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<!-- 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">
|
<!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">
|
width="48px" height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||||
<g id="x">
|
<g id="x">
|
||||||
</g>
|
</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"?>
|
<?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">
|
<!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">
|
height="48px" viewBox="0 0 48 48" enable-background="new 0 0 48 48" xml:space="preserve">
|
||||||
<g id="Combined">
|
<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
|
<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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
|
||||||
<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">
|
|
||||||
<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
|
<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"/>
|
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"/>
|
<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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
width="24px" height="24px" viewBox="0 0 24 24" fill="#F0EDE6">
|
||||||
<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">
|
|
||||||
<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
|
<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"/>
|
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
|
<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"),
|
listPresets: () => api("GET", "/api/presets"),
|
||||||
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
|
getPreset: (bank, program) => api("GET", `/api/presets/${bank}/${program}`),
|
||||||
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
|
activatePreset:(bank, program) => api("POST", `/api/presets/${bank}/${program}/activate`),
|
||||||
savePreset: (bank, program, name, tags) => api("PUT", "/api/presets", { bank, program, name, tags }),
|
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", { rate: parseInt(rate), period: parseInt(period) }),
|
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)}`),
|
searchModels: (q) => api("GET", `/api/tonehub/search?q=${encodeURIComponent(q)}`),
|
||||||
searchIrs: (q) => api("GET", `/api/tonehub/search/irs?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 }),
|
installModel: (url, name) => api("POST", "/api/models/tonedownload/install", { download_url: url, name }),
|
||||||
@@ -48,13 +52,13 @@ const T = {
|
|||||||
border: "#2A2A32",
|
border: "#2A2A32",
|
||||||
amber: "#E8A030",
|
amber: "#E8A030",
|
||||||
amberDim: "#7A5218",
|
amberDim: "#7A5218",
|
||||||
blue: "#3A7BA8",
|
blue: "#4A8BC8",
|
||||||
blueDim: "#1E4060",
|
blueDim: "#1E4060",
|
||||||
green: "#3AB87A",
|
green: "#3AB87A",
|
||||||
red: "#C84040",
|
red: "#E06060",
|
||||||
textPrimary: "#F0EDE6",
|
textPrimary: "#F0EDE6",
|
||||||
textSec: "#8888A0",
|
textSec: "#A0A0BC",
|
||||||
textDim: "#444458",
|
textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Block parameter definitions ─────────────────────────────────
|
// ── Block parameter definitions ─────────────────────────────────
|
||||||
@@ -96,7 +100,7 @@ const BLOCK_COLORS = {
|
|||||||
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
|
delay: T.blue, echo: T.blue, reverb: '#60B0D0',
|
||||||
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
|
chorus: T.green, flanger: '#50C080', phaser: '#80C050',
|
||||||
tremolo: T.green, compressor: '#D09040', gate: T.textSec, eq: '#7080D0',
|
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',
|
wah: '#D0A060', volume: T.textSec, tuner: '#80D0A0',
|
||||||
split: '#B080C0', merge: '#80B0C0',
|
split: '#B080C0', merge: '#80B0C0',
|
||||||
};
|
};
|
||||||
@@ -106,7 +110,7 @@ const BLOCK_DISPLAY_NAMES = {
|
|||||||
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
|
delay: "Digital Delay", echo: "Tape Echo", reverb: "Hall Reverb",
|
||||||
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
|
chorus: "Chorus", flanger: "Flanger", phaser: "Phase 90",
|
||||||
tremolo: "Tremolo", compressor: "Compressor", gate: "Noise Gate",
|
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",
|
boost: "Clean Boost", wah: "Wah", volume: "Volume", tuner: "Tuner",
|
||||||
loop: "FX Loop",
|
loop: "FX Loop",
|
||||||
split: "Split", merge: "Merge",
|
split: "Split", merge: "Merge",
|
||||||
@@ -135,10 +139,10 @@ function getBlockDisplayName(block) {
|
|||||||
|
|
||||||
// ── CSS ─────────────────────────────────────────────────────
|
// ── CSS ─────────────────────────────────────────────────────
|
||||||
const 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; }
|
* { 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}; }
|
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}; }
|
:root { --amber: ${T.amber}; --blue: ${T.blue}; --green: ${T.green}; }
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
@@ -163,11 +167,13 @@ const css = `
|
|||||||
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
|
.btn-primary:active { filter: brightness(.85); transform: scale(.97); }
|
||||||
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
|
.btn-ghost { background: ${T.surface}; color: ${T.textPrimary}; border: 1px solid ${T.border}; }
|
||||||
.btn-ghost:active { background: ${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;
|
justify-content: center; background: ${T.surface}; border: 1px solid ${T.border}; cursor: pointer;
|
||||||
font-size: 15px; transition: all .12s; flex-shrink: 0; }
|
font-size: 15px; transition: all .12s; flex-shrink: 0; }
|
||||||
.btn-icon:active { transform: scale(.93); }
|
.btn-icon:active { transform: scale(.93); }
|
||||||
.btn-icon.active { background: rgba(232,160,48,.15); border-color: ${T.amber}; color: ${T.amber}; }
|
.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 ── */
|
||||||
.badge { display: inline-flex; align-items: center; justify-content: center;
|
.badge { display: inline-flex; align-items: center; justify-content: center;
|
||||||
@@ -175,7 +181,7 @@ const css = `
|
|||||||
letter-spacing: .06em; text-transform: uppercase; }
|
letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
|
.badge-amber { background: rgba(232,160,48,.2); color: ${T.amber}; }
|
||||||
.badge-green { background: rgba(58,184,122,.2); color: ${T.green}; }
|
.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 slider ── */
|
||||||
.param-panel { background: ${T.surface}; padding: 8px 12px 6px; border-top: 1px solid ${T.border}; flex-shrink: 0; }
|
.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; }
|
color: ${T.textDim}; font-weight: 600; margin-bottom: 8px; }
|
||||||
.card { background: ${T.panel}; border: 1px solid ${T.border}; border-radius: 8px; padding: 14px; }
|
.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; }
|
.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 ──────────────────────────────────────────────────────
|
// ── Knob ──────────────────────────────────────────────────────
|
||||||
@@ -391,11 +423,12 @@ function ParameterPanel({ block,params,onParamChange,onParamChangeEnd,viewMode,o
|
|||||||
|
|
||||||
// ── VU Meter ──────────────────────────────────────────────────
|
// ── VU Meter ──────────────────────────────────────────────────
|
||||||
function VUMeter({level=0,height=24,vertical=false}){const segs=12,active=Math.round((level/100)*segs);
|
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;
|
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"}}>
|
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:(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>);
|
{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}}>
|
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 ──────────────────────────────────────────
|
// ── 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 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);}};
|
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}}>
|
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">
|
<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==="models"?"active":""}`}onClick={()=>setTab("models")}>NAM Models</button>
|
||||||
<button className={`toggle-btn ${tab==="irs"?"active":""}`}onClick={()=>setTab("irs")}>IR Cabs</button></div></div>
|
<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) ────────────────
|
// ── MOCK DATA (used when pedal not connected) ────────────────
|
||||||
const MOCK_BLOCKS=[
|
const MOCK_BLOCKS=[
|
||||||
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25,depth:70,mix:100}},
|
{id:"gate",type:"gate",name:"Noise Gate",bypassed:false,params:{threshold:40,release:25}},
|
||||||
{id:"split",type:"split",name:"Split",bypassed:false,params:{splitType:"y",panA:50,panB:50,activePath:"A"}},
|
{id:"comp",type:"compressor",name:"Compressor",bypassed:false,params:{threshold:50,ratio:4,gain:50}},
|
||||||
{id:"drive_a",type:"overdrive",name:"OD-808",path:"A",bypassed:false,params:{drive:65,tone:55,level:70}},
|
{id:"amp",type:"nam",name:"Twin Reverb",bypassed:false,params:{level:75}},
|
||||||
{id:"delay_a",type:"delay",name:"Digital Delay",path:"A",bypassed:false,params:{time:45,feedback:25,mix:40}},
|
{id:"cab",type:"ir",name:"2x12 American",bypassed:false,params:{level:75}},
|
||||||
{id:"fuzz_b",type:"fuzz",name:"Big Muff",path:"B",bypassed:false,params:{fuzz:75,tone:40,level:65}},
|
{id:"eq",type:"eq",name:"EQ",bypassed:false,params:{low:0,mid:0,high:0,level:70}},
|
||||||
{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:{subtype:"hall",decay:50,mix:30,tone:50}},
|
||||||
{id:"reverb",type:"reverb",name:"Hall Reverb",bypassed:false,params:{decay:60,mix:35,tone:50}},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_PRESETS=[
|
const MOCK_PRESETS=[
|
||||||
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Modern Hi-Gain"},
|
{num:1,bank:1,name:"Plexi Crunch"},{num:2,bank:1,name:"Blues Rock Lead"},
|
||||||
{num:3,bank:1,name:"Clean Chime"},{num:4,bank:1,name:"Blues Drive"},
|
{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: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"},
|
{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[globalBypass,setGlobalBypass]=useState(false);
|
||||||
const[loadingPreset,setLoadingPreset]=useState(false);
|
const[loadingPreset,setLoadingPreset]=useState(false);
|
||||||
const[focusBlockId,setFocusBlockId]=useState(null);
|
const[focusBlockId,setFocusBlockId]=useState(null);
|
||||||
const[modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
|
const [modelBrowserBlockId,setModelBrowserBlockId]=useState(null);
|
||||||
const[footswitchMode,setFootswitchMode]=useState("stomp");
|
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 ──
|
// ── Undo/Redo ──
|
||||||
const [undoStack, setUndoStack] = useState([]);
|
const [undoStack, setUndoStack] = useState([]);
|
||||||
@@ -543,6 +593,7 @@ export default function App(){
|
|||||||
// ── Main Menu & Settings ──
|
// ── Main Menu & Settings ──
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null
|
const [settingsView, setSettingsView] = useState(null); // "settings" | "eq" | null
|
||||||
|
const [overlayView, setOverlayView] = useState(null); // "save" | "bypass" | "wifi" | null
|
||||||
|
|
||||||
const handleMenuSelect = useCallback((itemId) => {
|
const handleMenuSelect = useCallback((itemId) => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
@@ -553,8 +604,14 @@ export default function App(){
|
|||||||
setState(prev => ({ ...prev, tuner_enabled: true }));
|
setState(prev => ({ ...prev, tuner_enabled: true }));
|
||||||
API.tunerToggle(true).catch(() => {});
|
API.tunerToggle(true).catch(() => {});
|
||||||
break;
|
break;
|
||||||
default:
|
case "save":
|
||||||
// save, bypass, wifi — placeholder for now
|
setOverlayView("save");
|
||||||
|
break;
|
||||||
|
case "bypass":
|
||||||
|
setOverlayView("bypass");
|
||||||
|
break;
|
||||||
|
case "wifi":
|
||||||
|
setOverlayView("wifi");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -581,7 +638,7 @@ export default function App(){
|
|||||||
useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
|
useEffect(()=>{loadState();const id=setInterval(loadState,3000);return()=>clearInterval(id);},[loadState]);
|
||||||
|
|
||||||
// VU animation fallback when no real data
|
// 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 ──
|
// ── Load presets from API ──
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
@@ -646,7 +703,7 @@ export default function App(){
|
|||||||
const p=presets.find(pr=>pr.num===num);
|
const p=presets.find(pr=>pr.num===num);
|
||||||
if(!p)return;
|
if(!p)return;
|
||||||
setCurrentPreset(num);
|
setCurrentPreset(num);
|
||||||
await handleLoadPreset(p.bank||1,num-1);
|
await handleLoadPreset((p.bank||1)-1,num-1);
|
||||||
},[presets,handleLoadPreset]);
|
},[presets,handleLoadPreset]);
|
||||||
|
|
||||||
// ── Blocks ──
|
// ── 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;});
|
setBlocks(prev=>{const arr=[...prev];const[m]=arr.splice(fromIdx,1);arr.splice(toIdx,0,m);return arr;});
|
||||||
},[takeSnapshot]);
|
},[takeSnapshot]);
|
||||||
const handleAddBlock=useCallback(()=>{
|
const handleAddBlock=useCallback(()=>{
|
||||||
|
setAddBlockType("picker");
|
||||||
|
},[takeSnapshot]);
|
||||||
|
const handleAddBlockType=useCallback((type)=>{
|
||||||
takeSnapshot();
|
takeSnapshot();
|
||||||
userChangedBlock.current=true;const id="block_"+Date.now();
|
userChangedBlock.current=true;
|
||||||
setBlocks(prev=>[...prev,{id,type:"overdrive",name:"New Drive",bypassed:false,params:{drive:30,tone:50,level:70}}]);
|
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);
|
setTimeout(()=>{userChangedBlock.current=false;},3000);
|
||||||
},[takeSnapshot]);
|
},[takeSnapshot]);
|
||||||
const handleRemoveBlock=useCallback((id)=>{
|
const handleRemoveBlock=useCallback((id)=>{
|
||||||
@@ -699,14 +765,14 @@ export default function App(){
|
|||||||
const next=Math.min(currentBank+1,8);
|
const next=Math.min(currentBank+1,8);
|
||||||
setCurrentBank(next);
|
setCurrentBank(next);
|
||||||
const bp=presets.filter(p=>p.bank===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]);
|
},[currentBank,presets,handleLoadPreset]);
|
||||||
|
|
||||||
const handleBankDown=useCallback(()=>{
|
const handleBankDown=useCallback(()=>{
|
||||||
const next=Math.max(currentBank-1,1);
|
const next=Math.max(currentBank-1,1);
|
||||||
setCurrentBank(next);
|
setCurrentBank(next);
|
||||||
const bp=presets.filter(p=>p.bank===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]);
|
},[currentBank,presets,handleLoadPreset]);
|
||||||
|
|
||||||
const handleGlobalBypass=useCallback(()=>{
|
const handleGlobalBypass=useCallback(()=>{
|
||||||
@@ -771,19 +837,24 @@ export default function App(){
|
|||||||
return(
|
return(
|
||||||
<><style>{css}</style>
|
<><style>{css}</style>
|
||||||
<div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
|
<div style={{width:"100%",height:"100vh",display:"flex",flexDirection:"column",background:T.bg,overflow:"hidden",
|
||||||
fontFamily:"'Inter',sans-serif",margin:"0 auto",
|
fontFamily:"'Space Grotesk',sans-serif",margin:"0 auto",
|
||||||
maxWidth:980,
|
maxWidth:isNarrow?undefined:980,
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{view==="main"&&(
|
{view==="main"&&(
|
||||||
<>
|
<>
|
||||||
{/* ══════ STATUS BAR ══════ */}
|
{/* ══════ STATUS BAR ══════ */}
|
||||||
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",
|
<div className="resp-status-bar" style={{display:"flex",justifyContent:"space-between",alignItems:"center",
|
||||||
padding:"4px 12px",background:T.panel,borderBottom:`1px solid ${T.border}`,flexShrink:0,minHeight:32}}>
|
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"}}>
|
<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%",
|
<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}`}}/>
|
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 */}
|
{/* Undo/Redo */}
|
||||||
<button className="btn-icon" style={{width:20,height:20,fontSize:11,opacity:undoStack.length>0?1:0.3}}
|
<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)">
|
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>}
|
{loadingPreset&&<span className="mono" style={{fontSize:8,color:T.amber}}>⟳</span>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{display:"flex",gap:8,alignItems:"center"}}>
|
<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}/>
|
<VUMeter level={vuLevel} height={24}/>
|
||||||
<span className="mono" style={{fontSize:9,color:T.textDim}}>CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}</span>
|
<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>}
|
||||||
<div style={{width:1,height:12,background:T.border}}/>
|
<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">
|
<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>
|
||||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}onClick={()=>setView("presets")}title="Presets">
|
<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>
|
</button>
|
||||||
<div style={{display:"flex",gap:2,alignItems:"center",background:T.surface,borderRadius:4,border:`1px solid ${T.border}`,padding:2,height:24}}>
|
<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={{
|
{["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,
|
background:state.tuner_enabled?'rgba(128,208,160,.15)':T.surface,
|
||||||
borderColor:state.tuner_enabled?T.green:T.border}}
|
borderColor:state.tuner_enabled?T.green:T.border}}
|
||||||
onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}>
|
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>
|
||||||
<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">
|
<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">
|
<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}}/>
|
<div style={{width:1,height:16,background:T.border}}/>
|
||||||
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}
|
<button className="btn-icon" style={{width:24,height:24,position:'relative'}}
|
||||||
onClick={()=>setMenuOpen(true)} title="Main Menu">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -843,8 +914,22 @@ export default function App(){
|
|||||||
<div style={{flex:1,display:"flex",overflow:"hidden"}}>
|
<div style={{flex:1,display:"flex",overflow:"hidden"}}>
|
||||||
|
|
||||||
{/* ─── Preset Sidebar ─── */}
|
{/* ─── Preset Sidebar ─── */}
|
||||||
<div style={{width:90,flexShrink:0,background:T.surface,borderRight:`1px solid ${T.border}`,
|
{(!isNarrow || sidebarOpen)&&<div style={{
|
||||||
display:"flex",flexDirection:"column",overflow:"hidden"}}>
|
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 */}
|
{/* Bank header */}
|
||||||
<div style={{padding:"6px 8px",textAlign:"center",borderBottom:`1px solid ${T.border}`,background:T.panel}}>
|
<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>
|
<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>
|
||||||
</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 ─── */}
|
{/* ─── Main Content Area ─── */}
|
||||||
<div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}>
|
<div style={{flex:1,display:"flex",flexDirection:"column",overflow:"hidden",minWidth:0}}>
|
||||||
@@ -893,6 +981,7 @@ export default function App(){
|
|||||||
onAddBlock={handleAddBlock}
|
onAddBlock={handleAddBlock}
|
||||||
onRemoveBlock={handleRemoveBlock}
|
onRemoveBlock={handleRemoveBlock}
|
||||||
onModelBrowser={handleOpenModelBrowser}
|
onModelBrowser={handleOpenModelBrowser}
|
||||||
|
onBlockSelected={handleAddBlockType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -955,7 +1044,7 @@ export default function App(){
|
|||||||
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
|
{view==="captures"&&<CapturesScreen onClose={()=>setView("main")}/>}
|
||||||
{view==="presets"&&(
|
{view==="presets"&&(
|
||||||
<div style={{display:"flex",flexDirection:"column",height:"100%",background:T.bg}}>
|
<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">
|
<div className="screen-body">
|
||||||
{presets.map((p,i)=>(
|
{presets.map((p,i)=>(
|
||||||
<div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
|
<div key={i} className={`preset-row ${p.num===currentPreset?"active":""}`}
|
||||||
@@ -1012,6 +1101,8 @@ export default function App(){
|
|||||||
onClose={()=>setSettingsView(null)}
|
onClose={()=>setSettingsView(null)}
|
||||||
onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})}
|
onMasterVolume={(v)=>API.masterVolume(v).catch(()=>{})}
|
||||||
onAudioProfile={(rate, period)=>API.setAudioProfile(rate, period).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></>
|
</div></>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const T = {
|
|||||||
border: "#2A2A32",
|
border: "#2A2A32",
|
||||||
amber: "#E8A030",
|
amber: "#E8A030",
|
||||||
amberDim: "#7A5218",
|
amberDim: "#7A5218",
|
||||||
blue: "#3A7BA8",
|
blue: "#4A8BC8",
|
||||||
blueDim: "#1E4060",
|
blueDim: "#1E4060",
|
||||||
green: "#3AB87A",
|
green: "#3AB87A",
|
||||||
red: "#C84040",
|
red: "#E06060",
|
||||||
textPrimary: "#F0EDE6",
|
textPrimary: "#F0EDE6",
|
||||||
textSec: "#8888A0",
|
textSec: "#A0A0BC",
|
||||||
textDim: "#444458",
|
textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Split type metadata ────────────────────────────
|
// ── Split type metadata ────────────────────────────
|
||||||
@@ -95,7 +95,7 @@ function BlockIcon({ type, size = 14, style = {} }) {
|
|||||||
alt={type || 'fx'}
|
alt={type || 'fx'}
|
||||||
style={{
|
style={{
|
||||||
width: size, height: size, flexShrink: 0,
|
width: size, height: size, flexShrink: 0,
|
||||||
filter: 'invert(0.95) brightness(2)',
|
filter: 'none',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
@@ -865,8 +865,8 @@ export default function BlockChain({
|
|||||||
</button>
|
</button>
|
||||||
{selectedBlockId && (
|
{selectedBlockId && (
|
||||||
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{
|
<button onClick={() => onRemoveBlock(selectedBlockId)} style={{
|
||||||
padding: "10px 14px", borderRadius: 7, background: "rgba(200,64,64,.15)",
|
padding: "10px 14px", borderRadius: 7, background: "rgba(224,96,96,.15)",
|
||||||
border: `1px solid rgba(200,64,64,.3)`, color: T.red, fontSize: 12,
|
border: `1px solid rgba(224,96,96,.3)`, color: T.red, fontSize: 12,
|
||||||
fontWeight: 600, cursor: "pointer", transition: "all .12s",
|
fontWeight: 600, cursor: "pointer", transition: "all .12s",
|
||||||
}}>
|
}}>
|
||||||
✕ Remove
|
✕ Remove
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const T = {
|
|||||||
border: "#2A2A32",
|
border: "#2A2A32",
|
||||||
amber: "#E8A030",
|
amber: "#E8A030",
|
||||||
amberDim: "#7A5218",
|
amberDim: "#7A5218",
|
||||||
blue: "#3A7BA8",
|
blue: "#4A8BC8",
|
||||||
blueDim: "#1E4060",
|
blueDim: "#1E4060",
|
||||||
green: "#3AB87A",
|
green: "#3AB87A",
|
||||||
red: "#C84040",
|
red: "#E06060",
|
||||||
textPrimary: "#F0EDE6",
|
textPrimary: "#F0EDE6",
|
||||||
textSec: "#8888A0",
|
textSec: "#A0A0BC",
|
||||||
textDim: "#444458",
|
textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Block param definitions (mirrored from App.jsx) ──
|
// ── Block param definitions (mirrored from App.jsx) ──
|
||||||
@@ -338,7 +338,7 @@ export default function FocusView({
|
|||||||
position: 'fixed', inset: 0, zIndex: 1000,
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
background: T.bg,
|
background: T.bg,
|
||||||
fontFamily: "'Inter', sans-serif",
|
fontFamily: "'Space Grotesk', sans-serif",
|
||||||
color: T.textPrimary,
|
color: T.textPrimary,
|
||||||
}}>
|
}}>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useRef, useEffect } from "react";
|
|||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_COLORS = {
|
const TYPE_COLORS = {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState, useRef, useCallback, useEffect } from "react";
|
|||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── EQ Band definitions ──
|
// ── EQ Band definitions ──
|
||||||
@@ -265,7 +265,7 @@ export default function GlobalEQ({ onClose }) {
|
|||||||
position: "fixed", inset: 0, zIndex: 998,
|
position: "fixed", inset: 0, zIndex: 998,
|
||||||
display: "flex", flexDirection: "column",
|
display: "flex", flexDirection: "column",
|
||||||
background: T.bg, color: T.textPrimary,
|
background: T.bg, color: T.textPrimary,
|
||||||
fontFamily: "'Inter', sans-serif",
|
fontFamily: "'Space Grotesk', sans-serif",
|
||||||
}}>
|
}}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
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 = {
|
const SETTINGS = {
|
||||||
"Audio": [
|
"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",
|
{ key: "sample_rate", label: "Sample Rate", type: "select", def: "48000",
|
||||||
options: [
|
options: [
|
||||||
{ value: "22050", label: "22.05 kHz" },
|
{ value: "22050", label: "22.05 kHz" },
|
||||||
@@ -17,7 +25,7 @@ const SETTINGS = {
|
|||||||
{ value: "48000", label: "48 kHz" },
|
{ value: "48000", label: "48 kHz" },
|
||||||
{ value: "88200", label: "88.2 kHz" },
|
{ value: "88200", label: "88.2 kHz" },
|
||||||
{ value: "96000", label: "96 kHz" },
|
{ value: "96000", label: "96 kHz" },
|
||||||
], icon: "📊" },
|
], icon: "📊", via: "audio_profile" },
|
||||||
{ key: "buffer_size", label: "Buffer Size", type: "select", def: "512",
|
{ key: "buffer_size", label: "Buffer Size", type: "select", def: "512",
|
||||||
options: [
|
options: [
|
||||||
{ value: "64", label: "64 (1.3ms)" },
|
{ value: "64", label: "64 (1.3ms)" },
|
||||||
@@ -25,14 +33,14 @@ const SETTINGS = {
|
|||||||
{ value: "256", label: "256 (5.3ms)" },
|
{ value: "256", label: "256 (5.3ms)" },
|
||||||
{ value: "512", label: "512 (10.6ms)" },
|
{ value: "512", label: "512 (10.6ms)" },
|
||||||
{ value: "1024",label: "1024 (21.3ms)" },
|
{ value: "1024",label: "1024 (21.3ms)" },
|
||||||
], icon: "📦" },
|
], icon: "📦", via: "audio_profile" },
|
||||||
{ key: "input_pad", label: "Input Pad", type: "select", def: "off",
|
{ key: "input_pad", label: "Input Pad", type: "select", def: "off",
|
||||||
options: [
|
options: [
|
||||||
{ value: "off", label: "Off (0 dB)" },
|
{ value: "off", label: "Off (0 dB)" },
|
||||||
{ value: "-6", label: "-6 dB" },
|
{ value: "-6", label: "-6 dB" },
|
||||||
{ value: "-12", label: "-12 dB" },
|
{ value: "-12", label: "-12 dB" },
|
||||||
{ value: "-18", label: "-18 dB" },
|
{ value: "-18", label: "-18 dB" },
|
||||||
], icon: "📥" },
|
], icon: "📥", via: "settings" },
|
||||||
{ key: "input_impedance", label: "Input Impedance", type: "select", def: "1m",
|
{ key: "input_impedance", label: "Input Impedance", type: "select", def: "1m",
|
||||||
options: [
|
options: [
|
||||||
{ value: "1m", label: "1 MΩ" },
|
{ value: "1m", label: "1 MΩ" },
|
||||||
@@ -40,7 +48,45 @@ const SETTINGS = {
|
|||||||
{ value: "250k", label: "250 kΩ" },
|
{ value: "250k", label: "250 kΩ" },
|
||||||
{ value: "150k", label: "150 kΩ" },
|
{ value: "150k", label: "150 kΩ" },
|
||||||
{ value: "68k", label: "68 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": [
|
"MIDI": [
|
||||||
{ key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni",
|
{ key: "midi_channel", label: "MIDI Channel", type: "select", def: "omni",
|
||||||
@@ -49,13 +95,13 @@ const SETTINGS = {
|
|||||||
...Array.from({ length: 16 }, (_, i) => ({
|
...Array.from({ length: 16 }, (_, i) => ({
|
||||||
value: String(i + 1), label: `Channel ${i + 1}`
|
value: String(i + 1), label: `Channel ${i + 1}`
|
||||||
})),
|
})),
|
||||||
], icon: "🎹" },
|
], icon: "🎹", via: "settings" },
|
||||||
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, icon: "⏱" },
|
{ key: "midi_clock", label: "MIDI Clock", type: "toggle", def: false, via: "settings", icon: "⏱" },
|
||||||
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, icon: "↔" },
|
{ key: "midi_thru", label: "MIDI Thru", type: "toggle", def: true, via: "settings", icon: "↔" },
|
||||||
],
|
],
|
||||||
"Display": [
|
"Display": [
|
||||||
{ key: "brightness", label: "Display Brightness", type: "slider", min: 1, max: 10, def: 8, unit: "", 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, icon: "🌙" },
|
{ key: "auto_dim", label: "Auto Dim", type: "toggle", def: true, via: "settings", icon: "🌙" },
|
||||||
{ key: "screen_saver", label: "Screen Saver", type: "select", def: "5m",
|
{ key: "screen_saver", label: "Screen Saver", type: "select", def: "5m",
|
||||||
options: [
|
options: [
|
||||||
{ value: "off", label: "Off" },
|
{ value: "off", label: "Off" },
|
||||||
@@ -63,10 +109,10 @@ const SETTINGS = {
|
|||||||
{ value: "5m", label: "5 min" },
|
{ value: "5m", label: "5 min" },
|
||||||
{ value: "15m", label: "15 min" },
|
{ value: "15m", label: "15 min" },
|
||||||
{ value: "30m", label: "30 min" },
|
{ value: "30m", label: "30 min" },
|
||||||
], icon: "💤" },
|
], icon: "💤", via: "settings" },
|
||||||
],
|
],
|
||||||
"Tempo": [
|
"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",
|
{ key: "tempo_division", label: "Tempo Division", type: "select", def: "quarter",
|
||||||
options: [
|
options: [
|
||||||
{ value: "half", label: "½ Note" },
|
{ value: "half", label: "½ Note" },
|
||||||
@@ -75,12 +121,23 @@ const SETTINGS = {
|
|||||||
{ value: "triplet", label: "⅛ Triplet" },
|
{ value: "triplet", label: "⅛ Triplet" },
|
||||||
{ value: "sixteenth", label: "¹⁄₁₆ Note" },
|
{ value: "sixteenth", label: "¹⁄₁₆ Note" },
|
||||||
{ value: "dotted_eighth", label: "Dotted ⅛" },
|
{ value: "dotted_eighth", label: "Dotted ⅛" },
|
||||||
], icon: "🎶" },
|
], icon: "🎶", via: "settings" },
|
||||||
{ key: "tap_tempo_mute", label: "Mute on Tap", type: "toggle", def: false, icon: "🔇" },
|
{ 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 }) {
|
function SettingsSlider({ label, value, min, max, unit, onChange }) {
|
||||||
const trackRef = useRef(null);
|
const trackRef = useRef(null);
|
||||||
const dragRef = useRef(false);
|
const dragRef = useRef(false);
|
||||||
@@ -138,45 +195,22 @@ function SettingsSlider({ label, value, min, max, unit, onChange }) {
|
|||||||
position: "relative", height: 32, display: "flex", alignItems: "center",
|
position: "relative", height: 32, display: "flex", alignItems: "center",
|
||||||
cursor: "pointer", touchAction: "none",
|
cursor: "pointer", touchAction: "none",
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{ width: "100%", height: 6, background: T.border, borderRadius: 3, position: "relative", overflow: "hidden" }}>
|
||||||
width: "100%", height: 6, background: T.border, borderRadius: 3,
|
<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" }} />
|
||||||
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>
|
||||||
<div style={{
|
<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" }} />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Select Widget ──
|
function SettingsSelect({ label, value, options, onChange }) {
|
||||||
function SettingsSelect({ label, value, options, onChange, icon }) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", background: T.panel, borderRadius: 6, padding: 4 }}>
|
||||||
display: "flex", gap: 4, flexWrap: "wrap",
|
|
||||||
background: T.panel, borderRadius: 6, padding: 4,
|
|
||||||
}}>
|
|
||||||
{options.map(opt => (
|
{options.map(opt => (
|
||||||
<button key={opt.value}
|
<button key={opt.value}
|
||||||
onClick={() => onChange?.(opt.value)}
|
onClick={() => onChange?.(opt.value)}
|
||||||
@@ -186,25 +220,18 @@ function SettingsSelect({ label, value, options, onChange, icon }) {
|
|||||||
background: value === opt.value ? T.amber : "transparent",
|
background: value === opt.value ? T.amber : "transparent",
|
||||||
color: value === opt.value ? "#000" : T.textSec,
|
color: value === opt.value ? "#000" : T.textSec,
|
||||||
transition: "all .1s", letterSpacing: ".03em",
|
transition: "all .1s", letterSpacing: ".03em",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap", minWidth: 0,
|
||||||
minWidth: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>{opt.label}</button>
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle Widget ──
|
|
||||||
function SettingsToggle({ label, value, onChange }) {
|
function SettingsToggle({ label, value, onChange }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 0" }}>
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
|
||||||
padding: "4px 0",
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange?.(!value)}
|
onClick={() => onChange?.(!value)}
|
||||||
@@ -227,7 +254,6 @@ function SettingsToggle({ label, value, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BPM/Tap Tempo Widget ──
|
|
||||||
function BpmWidget({ label, value, onChange }) {
|
function BpmWidget({ label, value, onChange }) {
|
||||||
const tapTimes = useRef([]);
|
const tapTimes = useRef([]);
|
||||||
|
|
||||||
@@ -247,178 +273,177 @@ function BpmWidget({ label, value, onChange }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
|
||||||
display: "flex", justifyContent: "space-between", alignItems: "center",
|
|
||||||
marginBottom: 6,
|
|
||||||
}}>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
<span style={{ fontSize: 11, fontWeight: 500, color: T.textSec }}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
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={handleTap}
|
<button onClick={() => onChange?.(Math.max(20, value - 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}>−</button>
|
||||||
style={{
|
<span style={{ flex: 1, textAlign: "center", fontFamily: "'JetBrains Mono', monospace", fontSize: 16, fontWeight: 700, color: T.amber }}>{value} BPM</span>
|
||||||
padding: "8px 20px", borderRadius: 8, border: `1px solid ${T.amber}66`,
|
<button onClick={() => onChange?.(Math.min(300, value + 1))} style={{ background: "none", border: "none", color: T.textDim, fontSize: 14, cursor: "pointer", padding: "0 4px" }}>+</button>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global Settings ──
|
// ── Global Settings ─────────────────────────────────────────────────────────
|
||||||
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile }) {
|
|
||||||
const [values, setValues] = useState(() => {
|
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings }) {
|
||||||
const init = {};
|
const [values, setValues] = useState(buildDefaults());
|
||||||
for (const group of Object.values(SETTINGS)) {
|
const [savedValues, setSavedValues] = useState(buildDefaults());
|
||||||
for (const s of group) {
|
const [loaded, setLoaded] = useState(false);
|
||||||
init[s.key] = s.def;
|
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;
|
||||||
}
|
}
|
||||||
|
if (audioProfile) {
|
||||||
|
if (audioProfile.rate != null) overrides.sample_rate = String(audioProfile.rate);
|
||||||
|
if (audioProfile.period != null) overrides.buffer_size = String(audioProfile.period);
|
||||||
}
|
}
|
||||||
return init;
|
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 groups = Object.entries(SETTINGS);
|
||||||
|
|
||||||
const update = useCallback((key, val) => {
|
const update = useCallback((key, val) => {
|
||||||
setValues(prev => ({ ...prev, [key]: val }));
|
setValues(prev => ({ ...prev, [key]: val }));
|
||||||
// Fire external callback for master volume
|
setSaveMsg(null);
|
||||||
if (key === "master_volume") {
|
}, []);
|
||||||
onMasterVolume?.(val);
|
|
||||||
|
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") {
|
// Apply instant-effect settings via dedicated endpoints first
|
||||||
// Get the latest values — use the new val for the changed key
|
const promises = [];
|
||||||
setValues(prev => {
|
if (changed.master_volume != null) {
|
||||||
const rate = key === "sample_rate" ? val : (prev.sample_rate || "48000");
|
onMasterVolume?.(changed.master_volume);
|
||||||
const period = key === "buffer_size" ? val : (prev.buffer_size || "512");
|
}
|
||||||
|
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));
|
onAudioProfile?.(parseInt(rate), parseInt(period));
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [onMasterVolume, onAudioProfile]);
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Space Grotesk', sans-serif" }}>
|
||||||
position: "fixed", inset: 0, zIndex: 998,
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: `1px solid ${T.border}`, background: T.panel, flexShrink: 0 }}>
|
||||||
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={{ display: "flex", alignItems: "center", gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<button onClick={onClose}
|
<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>
|
||||||
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>
|
||||||
<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>
|
</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>
|
</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]) => (
|
{groups.map(([groupName, settings]) => (
|
||||||
<div key={groupName}>
|
<div key={groupName}>
|
||||||
{/* Group header */}
|
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", color: T.textDim, padding: "14px 0 8px" }}>{groupName}</div>
|
||||||
<div style={{
|
<div style={{ display: "flex", flexDirection: "column", gap: 12, background: T.surface, borderRadius: 8, border: `1px solid ${T.border}`, padding: "12px 14px" }}>
|
||||||
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",
|
|
||||||
}}>
|
|
||||||
{settings.map(s => (
|
{settings.map(s => (
|
||||||
<div key={s.key} style={{
|
<div key={s.key} style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
|
||||||
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>
|
||||||
}}>
|
|
||||||
{/* 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 style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{s.type === "slider" && (
|
{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)} />}
|
||||||
<SettingsSlider
|
{s.type === "select" && <SettingsSelect label={s.label} value={values[s.key] ?? s.def} options={s.options} onChange={v => update(s.key, v)} />}
|
||||||
label={s.label}
|
{s.type === "toggle" && <SettingsToggle label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
|
||||||
value={values[s.key] ?? s.def}
|
{s.type === "bpm" && <BpmWidget label={s.label} value={values[s.key] ?? s.def} onChange={v => update(s.key, v)} />}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useRef, useEffect } from "react";
|
|||||||
|
|
||||||
const T = {
|
const T = {
|
||||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8", blueDim: "#1E4060",
|
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8", blueDim: "#1E4060",
|
||||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6", textSec: "#8888A0", textDim: "#444458",
|
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6", textSec: "#A0A0BC", textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
@@ -16,12 +16,13 @@ const MENU_ITEMS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function MainMenu({ onClose, onItemSelect }) {
|
export default function MainMenu({ onClose, onItemSelect }) {
|
||||||
const overlayRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
|
const backdropRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = overlayRef.current;
|
const el = sidebarRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// Trigger enter animation
|
// Trigger enter animation — slide sidebar into view
|
||||||
requestAnimationFrame(() => { el.style.transform = "translateX(0)"; });
|
requestAnimationFrame(() => { el.style.transform = "translateX(0)"; });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={backdropRef}
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed", inset: 0, zIndex: 999,
|
position: "fixed", inset: 0, zIndex: 999,
|
||||||
@@ -42,6 +43,7 @@ export default function MainMenu({ onClose, onItemSelect }) {
|
|||||||
>
|
>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div
|
<div
|
||||||
|
ref={sidebarRef}
|
||||||
style={{
|
style={{
|
||||||
width: 260, maxWidth: "75vw", height: "100%",
|
width: 260, maxWidth: "75vw", height: "100%",
|
||||||
background: T.panel,
|
background: T.panel,
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ const T = {
|
|||||||
border: "#2A2A32",
|
border: "#2A2A32",
|
||||||
amber: "#E8A030",
|
amber: "#E8A030",
|
||||||
amberDim: "#7A5218",
|
amberDim: "#7A5218",
|
||||||
blue: "#3A7BA8",
|
blue: "#4A8BC8",
|
||||||
blueDim: "#1E4060",
|
blueDim: "#1E4060",
|
||||||
green: "#3AB87A",
|
green: "#3AB87A",
|
||||||
red: "#C84040",
|
red: "#E06060",
|
||||||
textPrimary: "#F0EDE6",
|
textPrimary: "#F0EDE6",
|
||||||
textSec: "#8888A0",
|
textSec: "#A0A0BC",
|
||||||
textDim: "#444458",
|
textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
|
const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/';
|
||||||
@@ -365,7 +365,7 @@ export default function ModelBrowser({
|
|||||||
display: "flex", flexDirection: "column",
|
display: "flex", flexDirection: "column",
|
||||||
background: T.bg,
|
background: T.bg,
|
||||||
color: T.textPrimary,
|
color: T.textPrimary,
|
||||||
fontFamily: "'Inter', sans-serif",
|
fontFamily: "'Space Grotesk', sans-serif",
|
||||||
}}>
|
}}>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
// ── Design tokens (mirrored) ──────────────────────────────────
|
// ── Design tokens (mirrored) ──────────────────────────────────
|
||||||
const T = {
|
const T = {
|
||||||
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
|
||||||
amber: "#E8A030", amberDim: "#7A5218", blue: "#3A7BA8",
|
amber: "#E8A030", amberDim: "#7A5218", blue: "#4A8BC8",
|
||||||
green: "#3AB87A", red: "#C84040", textPrimary: "#F0EDE6",
|
green: "#3AB87A", red: "#E06060", textPrimary: "#F0EDE6",
|
||||||
textSec: "#8888A0", textDim: "#444458",
|
textSec: "#A0A0BC", textDim: "#7878A0",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── String labels matching _STRING_NAMES ──────────────────────
|
// ── 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 ── */
|
/* ── Reset & base ── */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body, #root { height: 100%; overflow: hidden; }
|
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) ── */
|
/* ── Utility classes (from pipedal) ── */
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
.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; }
|
letter-spacing: .06em; text-transform: uppercase; }
|
||||||
.badge-amber { background: rgba(232,160,48,.2); color: #E8A030; }
|
.badge-amber { background: rgba(232,160,48,.2); color: #E8A030; }
|
||||||
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
|
.badge-green { background: rgba(58,184,122,.2); color: #3AB87A; }
|
||||||
.badge-blue { background: rgba(58,123,168,.2); color: #3A7BA8; }
|
.badge-blue { background: rgba(74,139,200,.2); color: #4A8BC8; }
|
||||||
.badge-red { background: rgba(200,64,64,.2); color: #C84040; }
|
.badge-red { background: rgba(224,96,96,.2); color: #E06060; }
|
||||||
|
|
||||||
/* Button system */
|
/* Button system */
|
||||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
.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:active { transform: scale(.96); }
|
||||||
.btn-sm { padding: 5px 10px; font-size: 10px; }
|
.btn-sm { padding: 5px 10px; font-size: 10px; }
|
||||||
.btn-ghost { background: transparent; border-color: transparent; }
|
.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;
|
.btn-icon { display: inline-flex; align-items: center; justify-content: center;
|
||||||
width: 28px; height: 28px; border-radius: 5px; border: 1px solid #2A2A32;
|
width: 28px; height: 28px; border-radius: 5px; border: 1px solid #2A2A32;
|
||||||
background: #1C1C22; cursor: pointer; transition: all .12s; touch-action: manipulation; }
|
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;
|
align-items: center; justify-content: center; font-size: 16px; cursor: pointer;
|
||||||
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
|
border: 2px solid #2A2A32; background: #1C1C22; transition: all .12s; }
|
||||||
.transport-btn:active { transform: scale(.92); }
|
.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); }
|
.transport-btn.play { border-color: #3AB87A; background: rgba(58,184,122,.15); }
|
||||||
|
|
||||||
/* Switch toggle */
|
/* Switch toggle */
|
||||||
@@ -112,10 +129,105 @@ body { background: #0A0A0C; color: #F0EDE6; font-family: 'Inter', sans-serif; -w
|
|||||||
|
|
||||||
/* NAM chip */
|
/* NAM chip */
|
||||||
.nam-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
|
.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);
|
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: #3A7BA8; }
|
border-radius: 5px; font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #4A8BC8; }
|
||||||
|
|
||||||
/* Bypass LED */
|
/* Bypass LED */
|
||||||
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
.bypass-led { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.led-on { background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
|
.led-on { background: #3AB87A; box-shadow: 0 0 8px #3AB87A; }
|
||||||
.led-off { background: #444458; }
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||