feat(vision): add physical abacus column setting and fix remote flash toggle

Physical Abacus Columns Setting:
- Add physicalAbacusColumns to AbacusDisplayConfig (default: 4)
- Add database column with migration 0054
- Add slider UI in AbacusDisplayDropdown (range 1-21)
- Update AbacusVisionBridge to use setting instead of calculating from problem

Remote Camera Flash Toggle Fix:
- Add socket events for torch sync (set-torch, torch-state)
- Phone reports torch state to desktop on change/connection
- Desktop can control phone's torch remotely
- Add torch button in AbacusVisionBridge for phone camera mode
- Both local and remote flash toggles now work correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-12-31 22:33:58 -06:00
parent 8846cece93
commit b206eb3071
12 changed files with 1364 additions and 7 deletions

View File

@@ -0,0 +1,2 @@
-- Add physical_abacus_columns column to abacus_settings table
ALTER TABLE `abacus_settings` ADD `physical_abacus_columns` integer DEFAULT 4 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -379,6 +379,13 @@
"when": 1767208127241,
"tag": "0053_premium_expediter",
"breakpoints": true
},
{
"idx": 54,
"version": "6",
"when": 1767240895813,
"tag": "0054_new_mathemanic",
"breakpoints": true
}
]
}
}

View File

@@ -39,9 +39,10 @@ export default function RemoteCameraPage() {
stop: stopCamera,
flipCamera,
toggleTorch,
setTorch,
} = usePhoneCamera({ initialFacingMode: 'environment' })
// Remote camera connection
// Remote camera connection - pass setTorch for desktop control
const {
isConnected,
isSending,
@@ -54,7 +55,10 @@ export default function RemoteCameraPage() {
stopSending,
updateCalibration,
setFrameMode,
} = useRemoteCameraPhone()
emitTorchState,
} = useRemoteCameraPhone({
onTorchRequest: setTorch,
})
// Auto-detection state
const [calibration, setCalibration] = useState<CalibrationGrid | null>(null)
@@ -137,6 +141,13 @@ export default function RemoteCameraPage() {
}
}, [isConnected, videoStream, isCameraLoading, startCamera])
// Emit torch state to desktop when it changes or when connected
useEffect(() => {
if (isConnected) {
emitTorchState(isTorchOn, isTorchAvailable)
}
}, [isConnected, isTorchOn, isTorchAvailable, emitTorchState])
// Handle video ready - start sending immediately
const handleVideoReady = useCallback(
(width: number, height: number) => {

View File

@@ -287,6 +287,84 @@ export function AbacusDisplayDropdown({
isDark={isDark}
/>
</FormField>
<FormField
label={`Physical Abacus Columns: ${config.physicalAbacusColumns}`}
isFullscreen={isFullscreen}
isDark={isDark}
>
<div className={hstack({ gap: '2', alignItems: 'center' })}>
<input
type="range"
min="1"
max="21"
step="1"
value={config.physicalAbacusColumns}
onChange={(e) =>
updateConfig({ physicalAbacusColumns: parseInt(e.target.value, 10) })
}
className={css({
flex: 1,
h: '2',
bg: isFullscreen
? 'rgba(255, 255, 255, 0.2)'
: isDark
? 'gray.700'
: 'gray.200',
rounded: 'full',
appearance: 'none',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
ring: '2px',
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
},
'&::-webkit-slider-thumb': {
appearance: 'none',
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: isFullscreen ? 'blue.500' : 'brand.700',
transform: 'scale(1.1)',
},
},
'&::-moz-range-thumb': {
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
border: 'none',
cursor: 'pointer',
},
})}
onClick={(e) => e.stopPropagation()}
/>
<span
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
minW: '6',
textAlign: 'right',
})}
>
{config.physicalAbacusColumns}
</span>
</div>
<p
className={css({
fontSize: 'xs',
color: isFullscreen ? 'gray.400' : isDark ? 'gray.500' : 'gray.500',
mt: '1',
})}
>
For camera vision detection
</p>
</FormField>
</div>
</div>
</DropdownMenu.Content>

View File

@@ -36,6 +36,7 @@ export interface StudentInfo {
color: string
}
import { useAbacusDisplay } from '@soroban/abacus-react'
import { AbacusDock } from '../AbacusDock'
import { DecompositionProvider, DecompositionSection } from '../decomposition'
import { Tooltip, TooltipProvider } from '../ui/Tooltip'
@@ -633,6 +634,9 @@ export function ActiveSession({
// Check if abacus is docked (to force show submit button)
const { isDockedByUser, requestDock, undock, dock, setDockedValue } = useMyAbacus()
// Get abacus display config (for physical abacus column count in vision mode)
const { config: abacusDisplayConfig } = useAbacusDisplay()
// Sound effects
const { playSound } = usePracticeSoundEffects()
@@ -2140,7 +2144,7 @@ export function ActiveSession({
})}
>
<AbacusVisionBridge
columnCount={calculateAbacusColumns(attempt.problem.terms)}
columnCount={abacusDisplayConfig.physicalAbacusColumns}
onValueDetected={handleVisionValueDetected}
onClose={() => setIsVisionEnabled(false)}
/>

View File

@@ -85,12 +85,15 @@ export function AbacusVisionBridge({
frameRate: remoteFrameRate,
frameMode: remoteFrameMode,
videoDimensions: remoteVideoDimensions,
isTorchOn: remoteIsTorchOn,
isTorchAvailable: remoteIsTorchAvailable,
error: remoteError,
subscribe: remoteSubscribe,
unsubscribe: remoteUnsubscribe,
setPhoneFrameMode: remoteSetPhoneFrameMode,
sendCalibration: remoteSendCalibration,
clearCalibration: remoteClearCalibration,
setRemoteTorch,
} = useRemoteCameraDesktop()
// Handle switching to phone camera
@@ -412,8 +415,8 @@ export function AbacusVisionBridge({
</button>
</div>
{/* Camera controls (local camera only) */}
{cameraSource === 'local' && (
{/* Camera controls (local camera) */}
{cameraSource === 'local' && vision.availableDevices.length > 0 && (
<div
data-element="camera-controls"
className={css({
@@ -502,6 +505,44 @@ export function AbacusVisionBridge({
</div>
)}
{/* Camera controls (phone camera) */}
{cameraSource === 'phone' && remoteIsPhoneConnected && remoteIsTorchAvailable && (
<div
data-element="phone-camera-controls"
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
})}
>
{/* Remote torch toggle button */}
<button
type="button"
onClick={() => setRemoteTorch(!remoteIsTorchOn)}
data-action="toggle-remote-torch"
data-status={remoteIsTorchOn ? 'on' : 'off'}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
bg: remoteIsTorchOn ? 'yellow.600' : 'gray.700',
color: 'white',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
fontSize: 'lg',
_hover: { bg: remoteIsTorchOn ? 'yellow.500' : 'gray.600' },
})}
title={remoteIsTorchOn ? 'Turn off phone flash' : 'Turn on phone flash'}
>
{remoteIsTorchOn ? '🔦' : '💡'}
</button>
<span className={css({ color: 'gray.400', fontSize: 'sm' })}>Phone Flash</span>
</div>
)}
{/* Calibration mode toggle (both local and phone camera) */}
<div
data-element="calibration-mode"

View File

@@ -65,6 +65,9 @@ export const abacusSettings = sqliteTable('abacus_settings', {
nativeAbacusNumbers: integer('native_abacus_numbers', { mode: 'boolean' })
.notNull()
.default(false),
/** Number of columns on the user's physical abacus (for vision detection) */
physicalAbacusColumns: integer('physical_abacus_columns').notNull().default(4),
})
export type AbacusSettings = typeof abacusSettings.$inferSelect

View File

@@ -25,6 +25,10 @@ interface UseRemoteCameraDesktopReturn {
frameMode: FrameMode
/** Video dimensions from the phone (only available in raw mode) */
videoDimensions: { width: number; height: number } | null
/** Whether the phone's torch is on */
isTorchOn: boolean
/** Whether the phone has torch available */
isTorchAvailable: boolean
/** Error message if connection failed */
error: string | null
/** Subscribe to receive frames for a session */
@@ -37,6 +41,8 @@ interface UseRemoteCameraDesktopReturn {
sendCalibration: (corners: QuadCorners) => void
/** Clear desktop calibration on phone (go back to auto-detection) */
clearCalibration: () => void
/** Set phone's torch state */
setRemoteTorch: (on: boolean) => void
}
/**
@@ -57,6 +63,8 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
width: number
height: number
} | null>(null)
const [isTorchOn, setIsTorchOn] = useState(false)
const [isTorchAvailable, setIsTorchAvailable] = useState(false)
const [error, setError] = useState<string | null>(null)
const currentSessionId = useRef<string | null>(null)
@@ -130,11 +138,23 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
setError(errorMsg)
}
const handleTorchState = ({
isTorchOn: torchOn,
isTorchAvailable: torchAvailable,
}: {
isTorchOn: boolean
isTorchAvailable: boolean
}) => {
setIsTorchOn(torchOn)
setIsTorchAvailable(torchAvailable)
}
socket.on('remote-camera:connected', handleConnected)
socket.on('remote-camera:disconnected', handleDisconnected)
socket.on('remote-camera:status', handleStatus)
socket.on('remote-camera:frame', handleFrame)
socket.on('remote-camera:error', handleError)
socket.on('remote-camera:torch-state', handleTorchState)
return () => {
socket.off('remote-camera:connected', handleConnected)
@@ -142,6 +162,7 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
socket.off('remote-camera:status', handleStatus)
socket.off('remote-camera:frame', handleFrame)
socket.off('remote-camera:error', handleError)
socket.off('remote-camera:torch-state', handleTorchState)
}
}, [socket, calculateFrameRate])
@@ -176,6 +197,8 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
setError(null)
setVideoDimensions(null)
setFrameMode('raw')
setIsTorchOn(false)
setIsTorchAvailable(false)
}, [socket])
/**
@@ -226,6 +249,23 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
})
}, [socket])
/**
* Set phone's torch state
*/
const setRemoteTorch = useCallback(
(on: boolean) => {
if (!socket || !currentSessionId.current) return
socket.emit('remote-camera:set-torch', {
sessionId: currentSessionId.current,
on,
})
// Optimistically update local state
setIsTorchOn(on)
},
[socket]
)
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -243,11 +283,14 @@ export function useRemoteCameraDesktop(): UseRemoteCameraDesktopReturn {
frameRate,
frameMode,
videoDimensions,
isTorchOn,
isTorchAvailable,
error,
subscribe,
unsubscribe,
setPhoneFrameMode,
sendCalibration,
clearCalibration,
setRemoteTorch,
}
}

View File

@@ -21,6 +21,8 @@ interface UseRemoteCameraPhoneOptions {
targetWidth?: number
/** Target width for raw frames (default 640) */
rawWidth?: number
/** Callback when desktop requests torch change */
onTorchRequest?: (on: boolean) => void
}
/**
@@ -52,6 +54,8 @@ interface UseRemoteCameraPhoneReturn {
updateCalibration: (calibration: QuadCorners) => void
/** Set frame mode locally */
setFrameMode: (mode: FrameMode) => void
/** Emit torch state to desktop */
emitTorchState: (isTorchOn: boolean, isTorchAvailable: boolean) => void
}
/**
@@ -64,7 +68,14 @@ interface UseRemoteCameraPhoneReturn {
export function useRemoteCameraPhone(
options: UseRemoteCameraPhoneOptions = {}
): UseRemoteCameraPhoneReturn {
const { targetFps = 10, jpegQuality = 0.8, targetWidth = 300, rawWidth = 640 } = options
const { targetFps = 10, jpegQuality = 0.8, targetWidth = 300, rawWidth = 640, onTorchRequest } =
options
// Keep onTorchRequest in a ref to avoid stale closures
const onTorchRequestRef = useRef(onTorchRequest)
useEffect(() => {
onTorchRequestRef.current = onTorchRequest
}, [onTorchRequest])
// Calculate fixed output height based on aspect ratio (4 units tall by 3 units wide)
const targetHeight = Math.round(targetWidth * ABACUS_ASPECT_RATIO)
@@ -173,16 +184,24 @@ export function useRemoteCameraPhone(
calibrationRef.current = null
}
// Handle torch command from desktop
const handleSetTorch = ({ on }: { on: boolean }) => {
console.log('[RemoteCameraPhone] Desktop requested torch:', on)
onTorchRequestRef.current?.(on)
}
socket.on('remote-camera:error', handleError)
socket.on('remote-camera:set-mode', handleSetMode)
socket.on('remote-camera:set-calibration', handleSetCalibration)
socket.on('remote-camera:clear-calibration', handleClearCalibration)
socket.on('remote-camera:set-torch', handleSetTorch)
return () => {
socket.off('remote-camera:error', handleError)
socket.off('remote-camera:set-mode', handleSetMode)
socket.off('remote-camera:set-calibration', handleSetCalibration)
socket.off('remote-camera:clear-calibration', handleClearCalibration)
socket.off('remote-camera:set-torch', handleSetTorch)
}
}, [isSocketConnected]) // Re-run when socket connects
@@ -365,6 +384,21 @@ export function useRemoteCameraPhone(
calibrationRef.current = calibration
}, [])
/**
* Emit torch state to desktop
*/
const emitTorchState = useCallback((isTorchOn: boolean, isTorchAvailable: boolean) => {
const socket = socketRef.current
const sessionId = sessionIdRef.current
if (!socket || !sessionId) return
socket.emit('remote-camera:torch-state', {
sessionId,
isTorchOn,
isTorchAvailable,
})
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
@@ -390,5 +424,6 @@ export function useRemoteCameraPhone(
stopSending,
updateCalibration,
setFrameMode,
emitTorchState,
}
}

View File

@@ -1235,6 +1235,36 @@ export function initializeSocketServer(httpServer: HTTPServer) {
console.log(`🖥️ Desktop cleared remote camera calibration`)
})
// Remote Camera: Desktop commands phone to toggle torch
socket.on(
'remote-camera:set-torch',
({ sessionId, on }: { sessionId: string; on: boolean }) => {
// Forward torch command to phone
socket.to(`remote-camera:${sessionId}`).emit('remote-camera:set-torch', { on })
console.log(`🖥️ Desktop set remote camera torch: ${on}`)
}
)
// Remote Camera: Phone reports torch state to desktop
socket.on(
'remote-camera:torch-state',
({
sessionId,
isTorchOn,
isTorchAvailable,
}: {
sessionId: string
isTorchOn: boolean
isTorchAvailable: boolean
}) => {
// Forward torch state to desktop
socket.to(`remote-camera:${sessionId}`).emit('remote-camera:torch-state', {
isTorchOn,
isTorchAvailable,
})
}
)
// Remote Camera: Leave session
socket.on('remote-camera:leave', async ({ sessionId }: { sessionId: string }) => {
try {

View File

@@ -68,6 +68,8 @@ export interface AbacusDisplayConfig {
gestures: boolean;
soundEnabled: boolean;
soundVolume: number;
/** Number of columns on the user's physical abacus (for vision detection) */
physicalAbacusColumns: number;
}
export interface AbacusDisplayContextType {
@@ -90,6 +92,7 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
gestures: false,
soundEnabled: true,
soundVolume: 0.8,
physicalAbacusColumns: 4,
};
const STORAGE_KEY = "soroban-abacus-display-config";
@@ -165,6 +168,12 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
parsed.soundVolume <= 1
? parsed.soundVolume
: DEFAULT_CONFIG.soundVolume,
physicalAbacusColumns:
typeof parsed.physicalAbacusColumns === "number" &&
parsed.physicalAbacusColumns >= 1 &&
parsed.physicalAbacusColumns <= 21
? parsed.physicalAbacusColumns
: DEFAULT_CONFIG.physicalAbacusColumns,
};
}
} catch (error) {