From b206eb30712e4b98525a9fa2544c2b5a235a8b72 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 31 Dec 2025 22:33:58 -0600 Subject: [PATCH] feat(vision): add physical abacus column setting and fix remote flash toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/drizzle/0054_new_mathemanic.sql | 2 + apps/web/drizzle/meta/0054_snapshot.json | 1094 +++++++++++++++++ apps/web/drizzle/meta/_journal.json | 9 +- .../app/remote-camera/[sessionId]/page.tsx | 15 +- .../src/components/AbacusDisplayDropdown.tsx | 78 ++ .../src/components/practice/ActiveSession.tsx | 6 +- .../components/vision/AbacusVisionBridge.tsx | 45 +- apps/web/src/db/schema/abacus-settings.ts | 3 + apps/web/src/hooks/useRemoteCameraDesktop.ts | 43 + apps/web/src/hooks/useRemoteCameraPhone.ts | 37 +- apps/web/src/socket-server.ts | 30 + packages/abacus-react/src/AbacusContext.tsx | 9 + 12 files changed, 1364 insertions(+), 7 deletions(-) create mode 100644 apps/web/drizzle/0054_new_mathemanic.sql create mode 100644 apps/web/drizzle/meta/0054_snapshot.json diff --git a/apps/web/drizzle/0054_new_mathemanic.sql b/apps/web/drizzle/0054_new_mathemanic.sql new file mode 100644 index 00000000..f4235448 --- /dev/null +++ b/apps/web/drizzle/0054_new_mathemanic.sql @@ -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; diff --git a/apps/web/drizzle/meta/0054_snapshot.json b/apps/web/drizzle/meta/0054_snapshot.json new file mode 100644 index 00000000..d3df69be --- /dev/null +++ b/apps/web/drizzle/meta/0054_snapshot.json @@ -0,0 +1,1094 @@ +{ + "id": "b525367d-11a1-421a-ae22-fbc379752c84", + "prevId": "cf56b260-f1ed-4c52-93d6-5c8a7a8255fd", + "version": "6", + "dialect": "sqlite", + "tables": { + "abacus_settings": { + "name": "abacus_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "color_scheme": { + "name": "color_scheme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'place-value'" + }, + "bead_shape": { + "name": "bead_shape", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'diamond'" + }, + "color_palette": { + "name": "color_palette", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "hide_inactive_beads": { + "name": "hide_inactive_beads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "colored_numerals": { + "name": "colored_numerals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "scale_factor": { + "name": "scale_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "show_numbers": { + "name": "show_numbers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "animated": { + "name": "animated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "interactive": { + "name": "interactive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "gestures": { + "name": "gestures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sound_enabled": { + "name": "sound_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sound_volume": { + "name": "sound_volume", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.8 + } + }, + "indexes": {}, + "foreignKeys": { + "abacus_settings_user_id_users_id_fk": { + "name": "abacus_settings_user_id_users_id_fk", + "tableFrom": "abacus_settings", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "arcade_rooms": { + "name": "arcade_rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text(6)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_name": { + "name": "creator_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity": { + "name": "last_activity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ttl_minutes": { + "name": "ttl_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "game_name": { + "name": "game_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_config": { + "name": "game_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lobby'" + }, + "current_session_id": { + "name": "current_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_games_played": { + "name": "total_games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "arcade_rooms_code_unique": { + "name": "arcade_rooms_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "arcade_sessions": { + "name": "arcade_sessions", + "columns": { + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_game": { + "name": "current_game", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_url": { + "name": "game_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "game_state": { + "name": "game_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_players": { + "name": "active_players", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "arcade_sessions_room_id_arcade_rooms_id_fk": { + "name": "arcade_sessions_room_id_arcade_rooms_id_fk", + "tableFrom": "arcade_sessions", + "columnsFrom": [ + "room_id" + ], + "tableTo": "arcade_rooms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "arcade_sessions_user_id_users_id_fk": { + "name": "arcade_sessions_user_id_users_id_fk", + "tableFrom": "arcade_sessions", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "players": { + "name": "players", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "players_user_id_idx": { + "name": "players_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "players_user_id_users_id_fk": { + "name": "players_user_id_users_id_fk", + "tableFrom": "players", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_members": { + "name": "room_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_creator": { + "name": "is_creator", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen": { + "name": "last_seen", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_online": { + "name": "is_online", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_room_members_user_id_unique": { + "name": "idx_room_members_user_id_unique", + "columns": [ + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "room_members_room_id_arcade_rooms_id_fk": { + "name": "room_members_room_id_arcade_rooms_id_fk", + "tableFrom": "room_members", + "columnsFrom": [ + "room_id" + ], + "tableTo": "arcade_rooms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_member_history": { + "name": "room_member_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_joined_at": { + "name": "first_joined_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_action": { + "name": "last_action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "last_action_at": { + "name": "last_action_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_member_history_room_id_arcade_rooms_id_fk": { + "name": "room_member_history_room_id_arcade_rooms_id_fk", + "tableFrom": "room_member_history", + "columnsFrom": [ + "room_id" + ], + "tableTo": "arcade_rooms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_invitations": { + "name": "room_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by_name": { + "name": "invited_by_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "invitation_type": { + "name": "invitation_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manual'" + }, + "message": { + "name": "message", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responded_at": { + "name": "responded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_room_invitations_user_room": { + "name": "idx_room_invitations_user_room", + "columns": [ + "user_id", + "room_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "room_invitations_room_id_arcade_rooms_id_fk": { + "name": "room_invitations_room_id_arcade_rooms_id_fk", + "tableFrom": "room_invitations", + "columnsFrom": [ + "room_id" + ], + "tableTo": "arcade_rooms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_reports": { + "name": "room_reports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reporter_name": { + "name": "reporter_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reported_user_id": { + "name": "reported_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reported_user_name": { + "name": "reported_user_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_reports_room_id_arcade_rooms_id_fk": { + "name": "room_reports_room_id_arcade_rooms_id_fk", + "tableFrom": "room_reports", + "columnsFrom": [ + "room_id" + ], + "tableTo": "arcade_rooms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_bans": { + "name": "room_bans", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_name": { + "name": "user_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "banned_by": { + "name": "banned_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "banned_by_name": { + "name": "banned_by_name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_room_bans_user_room": { + "name": "idx_room_bans_user_room", + "columns": [ + "user_id", + "room_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "room_bans_room_id_arcade_rooms_id_fk": { + "name": "room_bans_room_id_arcade_rooms_id_fk", + "tableFrom": "room_bans", + "columnsFrom": [ + "room_id" + ], + "tableTo": "arcade_rooms", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_stats": { + "name": "user_stats", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_wins": { + "name": "total_wins", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "favorite_game_type": { + "name": "favorite_game_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "best_time": { + "name": "best_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "highest_accuracy": { + "name": "highest_accuracy", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_users_id_fk": { + "name": "user_stats_user_id_users_id_fk", + "tableFrom": "user_stats", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "guest_id": { + "name": "guest_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upgraded_at": { + "name": "upgraded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_guest_id_unique": { + "name": "users_guest_id_unique", + "columns": [ + "guest_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index ca13368f..d4054e88 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1767208127241, "tag": "0053_premium_expediter", "breakpoints": true + }, + { + "idx": 54, + "version": "6", + "when": 1767240895813, + "tag": "0054_new_mathemanic", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/web/src/app/remote-camera/[sessionId]/page.tsx b/apps/web/src/app/remote-camera/[sessionId]/page.tsx index c697bb73..4b5ba89a 100644 --- a/apps/web/src/app/remote-camera/[sessionId]/page.tsx +++ b/apps/web/src/app/remote-camera/[sessionId]/page.tsx @@ -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(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) => { diff --git a/apps/web/src/components/AbacusDisplayDropdown.tsx b/apps/web/src/components/AbacusDisplayDropdown.tsx index af7e80af..65982240 100644 --- a/apps/web/src/components/AbacusDisplayDropdown.tsx +++ b/apps/web/src/components/AbacusDisplayDropdown.tsx @@ -287,6 +287,84 @@ export function AbacusDisplayDropdown({ isDark={isDark} /> + + +
+ + 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()} + /> + + {config.physicalAbacusColumns} + +
+

+ For camera vision detection +

+
diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 6bd0c699..20b6a0af 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -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({ })} > setIsVisionEnabled(false)} /> diff --git a/apps/web/src/components/vision/AbacusVisionBridge.tsx b/apps/web/src/components/vision/AbacusVisionBridge.tsx index 18fafc27..83b11cfd 100644 --- a/apps/web/src/components/vision/AbacusVisionBridge.tsx +++ b/apps/web/src/components/vision/AbacusVisionBridge.tsx @@ -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({ - {/* Camera controls (local camera only) */} - {cameraSource === 'local' && ( + {/* Camera controls (local camera) */} + {cameraSource === 'local' && vision.availableDevices.length > 0 && (
)} + {/* Camera controls (phone camera) */} + {cameraSource === 'phone' && remoteIsPhoneConnected && remoteIsTorchAvailable && ( +
+ {/* Remote torch toggle button */} + + Phone Flash +
+ )} + {/* Calibration mode toggle (both local and phone camera) */}
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(null) const currentSessionId = useRef(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, } } diff --git a/apps/web/src/hooks/useRemoteCameraPhone.ts b/apps/web/src/hooks/useRemoteCameraPhone.ts index e75b0c0a..e476eb22 100644 --- a/apps/web/src/hooks/useRemoteCameraPhone.ts +++ b/apps/web/src/hooks/useRemoteCameraPhone.ts @@ -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, } } diff --git a/apps/web/src/socket-server.ts b/apps/web/src/socket-server.ts index adfd6849..43ac3c40 100644 --- a/apps/web/src/socket-server.ts +++ b/apps/web/src/socket-server.ts @@ -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 { diff --git a/packages/abacus-react/src/AbacusContext.tsx b/packages/abacus-react/src/AbacusContext.tsx index f92208db..78621ee0 100644 --- a/packages/abacus-react/src/AbacusContext.tsx +++ b/packages/abacus-react/src/AbacusContext.tsx @@ -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) {