Files
pi-multifx-pedal/ui/src/pedal/FootswitchModeContext.tsx
T
shawn 575535762c fix: bank display showing 1 instead of 0 + build fixes
- Removed 'e.bank||1' falsy trap (was converting bank 0 to 1)
- Fixed TypeScript errors: cp guard, unused vars, path aliases
- Added base: '/ui/' to vite config for correct asset paths
- Bank persistence already implemented (localStorage pedal-footswitch-bank)
- Rebuilt and deployed to src/web/ui-dist/
2026-06-13 10:18:33 -04:00

370 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* FootswitchMode Context — provides the current mode and mode switching
* to all footswitch components.
*/
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import type { FootswitchMode, ScribbleStripData, PedalState, PedalBlock } from './types';
import { apiPost, activatePreset } from "./hooks/usePedalState";
// ── Context Shape ─────────────────────────────────────────────
interface FootswitchModeContextValue {
mode: FootswitchMode;
setMode: (mode: FootswitchMode) => void;
/** Scribble strip data for each footswitch position (07) */
scribbleData: ScribbleStripData[];
/** Handle a footswitch press at a given position */
handleFootswitch: (index: number) => void;
/** Handle secondary action (double-tap / long-press) at a given position */
handleSecondary: (index: number) => void;
/** Current banks/presets for preset mode */
currentBankIndex: number;
currentPresetIndex: number;
setBank: (bank: number) => void;
setPreset: (preset: number) => void;
/** Whether the preset has been modified */
dirty: boolean;
}
const FootswitchModeContext = createContext<FootswitchModeContextValue | null>(null);
// ── Mode-specific scribble strip generators ───────────────────
function stompScribbles(
blocks: PedalBlock[],
_state: PedalState | null,
): ScribbleStripData[] {
const items: ScribbleStripData[] = [];
for (let i = 0; i < 8; i++) {
const block = blocks[i];
if (block) {
items.push({
key: `stomp-${block.id}`,
label: block.title.length > 6 ? block.title.slice(0, 6) : block.title,
sublabel: block.bypassed ? 'BYP' : 'ON',
color: block.bypassed ? 'off' : 'green',
active: !block.bypassed,
bypassed: block.bypassed,
});
} else {
items.push({
key: `stomp-empty-${i}`,
label: '—',
sublabel: '',
color: 'off',
active: false,
bypassed: false,
});
}
}
return items;
}
function presetScribbles(
state: PedalState | null,
bankIndex: number,
bankNames: string[],
): ScribbleStripData[] {
const items: ScribbleStripData[] = [];
// FS 0-1: Bank navigation
items.push({
key: 'bank-down',
label: 'BANK',
sublabel: bankIndex > 0 ? `${bankNames[bankIndex - 1] || ''}` : '—',
color: 'blue',
active: bankIndex > 0,
});
items.push({
key: 'bank-up',
label: 'BANK',
sublabel: bankIndex < bankNames.length - 1 ? `${bankNames[bankIndex + 1] || '+'}` : '—',
color: 'blue',
active: bankIndex < bankNames.length - 1,
});
// FS 2-7: Preset slots
for (let i = 0; i < 6; i++) {
const presetIdx = i;
const isActive = state?.current_preset?.program === presetIdx &&
state?.current_preset?.bank === bankIndex;
items.push({
key: `preset-${presetIdx}`,
label: String(presetIdx + 1),
sublabel: state?.current_preset && state.current_preset.program === presetIdx
? state.current_preset.name : '',
color: isActive ? 'green' : 'off',
active: isActive,
});
}
return items;
}
function snapshotScribbles(
snapshots: { name: string; active: boolean }[],
): ScribbleStripData[] {
const items: ScribbleStripData[] = [];
for (let i = 0; i < 8; i++) {
const snap = snapshots[i];
if (snap) {
items.push({
key: `snapshot-${i}`,
label: snap.name.length > 6 ? snap.name.slice(0, 6) : (snap.name || `Snap ${i + 1}`),
sublabel: snap.active ? 'ACTIVE' : '',
color: snap.active ? 'amber' : 'off',
active: snap.active,
});
} else {
items.push({
key: `snapshot-empty-${i}`,
label: `Snap ${i + 1}`,
sublabel: '',
color: 'off',
active: false,
});
}
}
return items;
}
function comboScribbles(
blocks: PedalBlock[],
state: PedalState | null,
bankNames: string[],
bankIndex: number,
): ScribbleStripData[] {
// Combo = first 4 stomp + bank/preset info on last 4
const stomp = stompScribbles(blocks.slice(0, 4), state);
// FS 4: bank down, FS 5: bank up, FS 6-7: preset select
return [
...stomp.slice(0, 4),
{
key: 'bank-down',
label: 'BANK',
sublabel: bankIndex > 0 ? '▼' : '—',
color: 'blue',
active: bankIndex > 0,
},
{
key: 'bank-up',
label: 'BANK',
sublabel: bankIndex < bankNames.length - 1 ? '▲' : '—',
color: 'blue',
active: bankIndex < bankNames.length - 1,
},
{
key: 'preset-select',
label: state?.current_preset?.name?.slice(0, 6) || '—',
sublabel: state?.current_preset ? `B${bankIndex + 1}` : '',
color: 'amber',
active: true,
},
{
key: 'global-bypass',
label: 'BYP',
sublabel: state?.bypass ? 'ON' : 'OFF',
color: state?.bypass ? 'red' : 'off',
active: !!state?.bypass,
},
];
}
// ── Provider ──────────────────────────────────────────────────
interface FootswitchModeProviderProps {
children: ReactNode;
state: PedalState | null;
connected: boolean;
refresh: () => void;
blocks: PedalBlock[];
bankNames: string[];
/** Current snapshot definitions */
snapshots: { name: string; active: boolean }[];
/** Called when a block toggle needs to happen */
onToggleBlock?: (blockId: number, bypassed: boolean) => void;
}
export function FootswitchModeProvider({
children,
state,
connected,
refresh,
blocks,
bankNames,
snapshots,
onToggleBlock,
}: FootswitchModeProviderProps) {
const [mode, setMode] = useState<FootswitchMode>('stomp');
const [currentBankIndex, setCurrentBankIndex] = useState(() => {
try {
const saved = localStorage.getItem('pedal-footswitch-bank');
return saved !== null ? parseInt(saved, 10) : 0;
} catch {
return 0;
}
});
const [currentPresetIndex, setCurrentPresetIndex] = useState(0);
const [dirty, _setDirty] = useState(false);
// Persist bank index to localStorage
useEffect(() => {
try {
localStorage.setItem('pedal-footswitch-bank', String(currentBankIndex));
} catch { /* quota exceeded or storage disabled */ }
}, [currentBankIndex]);
// Generate scribble data based on mode
let scribbleData: ScribbleStripData[];
switch (mode) {
case 'stomp':
scribbleData = stompScribbles(blocks, state);
break;
case 'preset':
scribbleData = presetScribbles(state, currentBankIndex, bankNames);
break;
case 'snapshot':
scribbleData = snapshotScribbles(snapshots);
break;
case 'combo':
scribbleData = comboScribbles(blocks, state, bankNames, currentBankIndex);
break;
default:
scribbleData = stompScribbles(blocks, state);
}
const handleFootswitch = useCallback((index: number) => {
switch (mode) {
case 'stomp': {
const block = blocks[index];
if (block && onToggleBlock) {
onToggleBlock(block.id, !block.bypassed);
}
break;
}
case 'preset': {
// FS 0 = bank down, FS 1 = bank up, FS 2-7 = presets
if (index === 0) {
setCurrentBankIndex(prev => Math.max(0, prev - 1));
} else if (index === 1) {
setCurrentBankIndex(prev => Math.min(bankNames.length - 1, prev + 1));
} else {
const presetIndex = index - 2;
if (connected) {
activatePreset(currentBankIndex, presetIndex).then(() => refresh());
setCurrentPresetIndex(presetIndex);
}
}
break;
}
case 'snapshot': {
// Recall snapshot at index
if (connected) {
apiPost(`/api/snapshots/${index}/recall`).catch(() => {});
}
break;
}
case 'combo': {
// FS 0-3 = stomp toggles, FS 4 = bank down, FS 5 = bank up,
// FS 6 = open preset browser, FS 7 = global bypass
if (index < 4) {
const block = blocks[index];
if (block && onToggleBlock) {
onToggleBlock(block.id, !block.bypassed);
}
} else if (index === 4) {
setCurrentBankIndex(prev => Math.max(0, prev - 1));
} else if (index === 5) {
setCurrentBankIndex(prev => Math.min(bankNames.length - 1, prev + 1));
} else if (index === 6) {
setMode('preset');
} else if (index === 7) {
if (connected) {
apiPost('/api/bypass', { bypass: !state?.bypass }).catch(() => {});
}
}
break;
}
}
}, [mode, blocks, onToggleBlock, connected, currentBankIndex, bankNames, state, refresh]);
const handleSecondary = useCallback((index: number) => {
// Double-tap / long-press secondary actions per mode
switch (mode) {
case 'stomp': {
// Secondary: toggle tuner on FS 7 (last footswitch), or reset block params
if (index === 7 && connected) {
apiPost('/api/tuner', { enabled: !state?.tuner_enabled }).catch(() => {});
}
break;
}
case 'preset': {
// Secondary on bank switches: jump to first/last bank
if (index === 0) {
setCurrentBankIndex(0);
} else if (index === 1) {
setCurrentBankIndex(bankNames.length - 1);
}
break;
}
case 'snapshot': {
// Secondary: save current state to snapshot
if (connected) {
apiPost(`/api/snapshots/${index}/save`).catch(() => {});
}
break;
}
case 'combo': {
// Secondary on stomp blocks: edit block params (would open param panel)
// Secondary on bypass: enter tuner mode
if (index === 7 && connected) {
apiPost('/api/tuner', { enabled: !state?.tuner_enabled }).catch(() => {});
}
break;
}
}
}, [mode, state, connected, bankNames]);
const setBank = useCallback((bank: number) => {
setCurrentBankIndex(bank);
}, []);
const setPreset = useCallback((preset: number) => {
setCurrentPresetIndex(preset);
if (connected) {
activatePreset(currentBankIndex, preset).then(() => refresh());
}
}, [connected, currentBankIndex, refresh]);
const value: FootswitchModeContextValue = {
mode,
setMode,
scribbleData,
handleFootswitch,
handleSecondary,
currentBankIndex,
currentPresetIndex,
setBank,
setPreset,
dirty,
};
return (
<FootswitchModeContext.Provider value={value}>
{children}
</FootswitchModeContext.Provider>
);
}
// ── Consumer Hook ─────────────────────────────────────────────
export function useFootswitchMode(): FootswitchModeContextValue {
const ctx = useContext(FootswitchModeContext);
if (!ctx) {
throw new Error('useFootswitchMode must be used within a FootswitchModeProvider');
}
return ctx;
}