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:
2
apps/web/drizzle/0054_new_mathemanic.sql
Normal file
2
apps/web/drizzle/0054_new_mathemanic.sql
Normal 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;
|
||||
1094
apps/web/drizzle/meta/0054_snapshot.json
Normal file
1094
apps/web/drizzle/meta/0054_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -379,6 +379,13 @@
|
||||
"when": 1767208127241,
|
||||
"tag": "0053_premium_expediter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "6",
|
||||
"when": 1767240895813,
|
||||
"tag": "0054_new_mathemanic",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user