Yingtao-Zheng commited on
Commit
a6dbd37
·
1 Parent(s): c1ac1f8

MERGE EVERYTHING from feature/integration-cleaned :((((((((((((((((((((((((((((((((((((((((((((((( so tired

Browse files
README.md CHANGED
@@ -46,6 +46,12 @@ python -m uvicorn main:app --host 0.0.0.0 --port 7860
46
 
47
  Then open http://localhost:7860.
48
 
 
 
 
 
 
 
49
  **OpenCV demo:**
50
 
51
  ```bash
 
46
 
47
  Then open http://localhost:7860.
48
 
49
+ **Frontend dev server (optional, for React development):**
50
+
51
+ ```bash
52
+ npm run dev
53
+ ```
54
+
55
  **OpenCV demo:**
56
 
57
  ```bash
main.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
2
  from fastapi.staticfiles import StaticFiles
3
  from fastapi.responses import FileResponse
@@ -180,6 +182,17 @@ async def lifespan(app):
180
  print("[OK] XGBoostPipeline loaded")
181
  except Exception as e:
182
  print(f"[ERR] Failed to load XGBoostPipeline: {e}")
 
 
 
 
 
 
 
 
 
 
 
183
  yield
184
  _inference_executor.shutdown(wait=False)
185
  print(" Shutting down Focus Guard API...")
@@ -504,6 +517,14 @@ def _process_frame_safe(pipeline, frame, model_name: str):
504
  with _pipeline_locks[model_name]:
505
  return pipeline.process_frame(frame)
506
 
 
 
 
 
 
 
 
 
507
  # ================ WEBRTC SIGNALING ================
508
 
509
  @app.post("/api/webrtc/offer")
@@ -575,7 +596,7 @@ async def websocket_endpoint(websocket: WebSocket):
575
  running = True
576
  event_buffer = _EventBuffer(flush_interval=2.0)
577
 
578
- # Latest frame slot only the most recent frame is kept, older ones are dropped.
579
  # Using a dict so nested functions can mutate without nonlocal issues.
580
  _slot = {"frame": None}
581
  _frame_ready = asyncio.Event()
@@ -657,10 +678,8 @@ async def websocket_endpoint(websocket: WebSocket):
657
  continue
658
  frame = cv2.resize(frame, (640, 480))
659
 
660
- model_name = _cached_model_name
661
- if model_name not in pipelines or pipelines.get(model_name) is None:
662
- model_name = "mlp"
663
- active_pipeline = pipelines.get(model_name)
664
 
665
  landmarks_list = None
666
  if active_pipeline is not None:
 
1
+ from __future__ import annotations
2
+
3
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
4
  from fastapi.staticfiles import StaticFiles
5
  from fastapi.responses import FileResponse
 
182
  print("[OK] XGBoostPipeline loaded")
183
  except Exception as e:
184
  print(f"[ERR] Failed to load XGBoostPipeline: {e}")
185
+ resolved_model = _first_available_pipeline_name(_cached_model_name)
186
+ if resolved_model is not None and resolved_model != _cached_model_name:
187
+ _cached_model_name = resolved_model
188
+ async with aiosqlite.connect(db_path) as db:
189
+ await db.execute(
190
+ "UPDATE user_settings SET model_name = ? WHERE id = 1",
191
+ (_cached_model_name,),
192
+ )
193
+ await db.commit()
194
+ if resolved_model is not None:
195
+ print(f"[OK] Active model set to {resolved_model}")
196
  yield
197
  _inference_executor.shutdown(wait=False)
198
  print(" Shutting down Focus Guard API...")
 
517
  with _pipeline_locks[model_name]:
518
  return pipeline.process_frame(frame)
519
 
520
+ def _first_available_pipeline_name(preferred: str | None = None) -> str | None:
521
+ if preferred and preferred in pipelines and pipelines.get(preferred) is not None:
522
+ return preferred
523
+ for name, pipeline in pipelines.items():
524
+ if pipeline is not None:
525
+ return name
526
+ return None
527
+
528
  # ================ WEBRTC SIGNALING ================
529
 
530
  @app.post("/api/webrtc/offer")
 
596
  running = True
597
  event_buffer = _EventBuffer(flush_interval=2.0)
598
 
599
+ # Latest frame slot: keep only the newest frame and drop stale ones.
600
  # Using a dict so nested functions can mutate without nonlocal issues.
601
  _slot = {"frame": None}
602
  _frame_ready = asyncio.Event()
 
678
  continue
679
  frame = cv2.resize(frame, (640, 480))
680
 
681
+ model_name = _first_available_pipeline_name(_cached_model_name)
682
+ active_pipeline = pipelines.get(model_name) if model_name is not None else None
 
 
683
 
684
  landmarks_list = None
685
  if active_pipeline is not None:
models/face_mesh.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import os
2
  import time
3
  from pathlib import Path
@@ -44,7 +46,10 @@ class FaceMeshDetector:
44
  ):
45
  model_path = _ensure_model()
46
  options = FaceLandmarkerOptions(
47
- base_options=mp_tasks.BaseOptions(model_asset_path=model_path),
 
 
 
48
  num_faces=max_num_faces,
49
  min_face_detection_confidence=min_detection_confidence,
50
  min_face_presence_confidence=min_detection_confidence,
 
1
+ from __future__ import annotations
2
+
3
  import os
4
  import time
5
  from pathlib import Path
 
46
  ):
47
  model_path = _ensure_model()
48
  options = FaceLandmarkerOptions(
49
+ base_options=mp_tasks.BaseOptions(
50
+ model_asset_path=model_path,
51
+ delegate=mp_tasks.BaseOptions.Delegate.CPU,
52
+ ),
53
  num_faces=max_num_faces,
54
  min_face_detection_confidence=min_detection_confidence,
55
  min_face_presence_confidence=min_detection_confidence,
models/head_pose.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import math
2
 
3
  import cv2
 
1
+ from __future__ import annotations
2
+
3
  import math
4
 
5
  import cv2
package-lock.json CHANGED
@@ -54,6 +54,7 @@
54
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
55
  "dev": true,
56
  "license": "MIT",
 
57
  "dependencies": {
58
  "@babel/code-frame": "^7.29.0",
59
  "@babel/generator": "^7.29.0",
@@ -1428,6 +1429,7 @@
1428
  "integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==",
1429
  "dev": true,
1430
  "license": "MIT",
 
1431
  "dependencies": {
1432
  "csstype": "^3.2.2"
1433
  }
@@ -1469,6 +1471,7 @@
1469
  "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
1470
  "dev": true,
1471
  "license": "MIT",
 
1472
  "bin": {
1473
  "acorn": "bin/acorn"
1474
  },
@@ -1574,6 +1577,7 @@
1574
  }
1575
  ],
1576
  "license": "MIT",
 
1577
  "dependencies": {
1578
  "baseline-browser-mapping": "^2.9.0",
1579
  "caniuse-lite": "^1.0.30001759",
@@ -1795,6 +1799,7 @@
1795
  "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
1796
  "dev": true,
1797
  "license": "MIT",
 
1798
  "dependencies": {
1799
  "@eslint-community/eslint-utils": "^4.8.0",
1800
  "@eslint-community/regexpp": "^4.12.1",
@@ -2481,6 +2486,7 @@
2481
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
2482
  "dev": true,
2483
  "license": "MIT",
 
2484
  "engines": {
2485
  "node": ">=12"
2486
  },
@@ -2542,6 +2548,7 @@
2542
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
2543
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
2544
  "license": "MIT",
 
2545
  "engines": {
2546
  "node": ">=0.10.0"
2547
  }
@@ -2775,6 +2782,7 @@
2775
  "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
2776
  "dev": true,
2777
  "license": "MIT",
 
2778
  "dependencies": {
2779
  "esbuild": "^0.27.0",
2780
  "fdir": "^6.5.0",
@@ -2896,6 +2904,7 @@
2896
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
2897
  "dev": true,
2898
  "license": "MIT",
 
2899
  "funding": {
2900
  "url": "https://github.com/sponsors/colinhacks"
2901
  }
 
54
  "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
55
  "dev": true,
56
  "license": "MIT",
57
+ "peer": true,
58
  "dependencies": {
59
  "@babel/code-frame": "^7.29.0",
60
  "@babel/generator": "^7.29.0",
 
1429
  "integrity": "sha512-tORuanb01iEzWvMGVGv2ZDhYZVeRMrw453DCSAIn/5yvcSVnMoUMTyf33nQJLahYEnv9xqrTNbgz4qY5EfSh0g==",
1430
  "dev": true,
1431
  "license": "MIT",
1432
+ "peer": true,
1433
  "dependencies": {
1434
  "csstype": "^3.2.2"
1435
  }
 
1471
  "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
1472
  "dev": true,
1473
  "license": "MIT",
1474
+ "peer": true,
1475
  "bin": {
1476
  "acorn": "bin/acorn"
1477
  },
 
1577
  }
1578
  ],
1579
  "license": "MIT",
1580
+ "peer": true,
1581
  "dependencies": {
1582
  "baseline-browser-mapping": "^2.9.0",
1583
  "caniuse-lite": "^1.0.30001759",
 
1799
  "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
1800
  "dev": true,
1801
  "license": "MIT",
1802
+ "peer": true,
1803
  "dependencies": {
1804
  "@eslint-community/eslint-utils": "^4.8.0",
1805
  "@eslint-community/regexpp": "^4.12.1",
 
2486
  "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
2487
  "dev": true,
2488
  "license": "MIT",
2489
+ "peer": true,
2490
  "engines": {
2491
  "node": ">=12"
2492
  },
 
2548
  "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
2549
  "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
2550
  "license": "MIT",
2551
+ "peer": true,
2552
  "engines": {
2553
  "node": ">=0.10.0"
2554
  }
 
2782
  "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
2783
  "dev": true,
2784
  "license": "MIT",
2785
+ "peer": true,
2786
  "dependencies": {
2787
  "esbuild": "^0.27.0",
2788
  "fdir": "^6.5.0",
 
2904
  "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
2905
  "dev": true,
2906
  "license": "MIT",
2907
+ "peer": true,
2908
  "funding": {
2909
  "url": "https://github.com/sponsors/colinhacks"
2910
  }
src/App.css CHANGED
@@ -40,11 +40,19 @@ body {
40
  display: flex;
41
  align-items: center;
42
  justify-content: center; /* Center buttons horizontally */
 
 
 
43
  box-shadow: 0 2px 5px rgba(0,0,0,0.05);
44
  position: fixed;
45
  top: 0;
 
 
46
  width: 100%;
47
  z-index: 1000;
 
 
 
48
  }
49
 
50
  .menu-btn {
@@ -106,15 +114,16 @@ body {
106
  /* PAGE A SPECIFIC */
107
  #page-a {
108
  justify-content: center; /* Center vertically */
 
109
  margin-top: -40px;
110
- flex: 1;
111
  }
112
 
113
  #page-a h1 {
114
  font-size: 80px;
115
  margin: 0 0 10px 0;
116
  color: #000;
117
- text-align: center;
118
  }
119
 
120
  #page-a p {
@@ -144,13 +153,13 @@ body {
144
  #page-b {
145
  justify-content: space-evenly; /* Distribute vertical space */
146
  padding-bottom: 20px;
147
- min-height: calc(100vh - 60px);
148
  }
149
 
150
  /* 1. Display Area */
151
  #display-area {
152
  width: 60%;
153
- height: 50vh;
154
  min-height: 300px;
155
  border: 2px solid #ddd;
156
  border-radius: 12px;
@@ -161,13 +170,330 @@ body {
161
  color: #555;
162
  font-size: 24px;
163
  position: relative;
 
164
  overflow: hidden;
165
  }
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  #display-area video {
168
  width: 100%;
169
  height: 100%;
170
- object-fit: cover;
171
  }
172
 
173
  /* 2. Timeline Area */
@@ -365,6 +691,7 @@ body {
365
  #focus-chart {
366
  display: block;
367
  margin: 0 auto;
 
368
  max-width: 100%;
369
  }
370
 
@@ -409,13 +736,15 @@ body {
409
  }
410
 
411
  .btn-view {
412
- padding: 6px 12px;
413
  background: #007BFF;
414
  color: white;
415
  border: none;
416
- border-radius: 5px;
417
  cursor: pointer;
418
  font-family: 'Nunito', sans-serif;
 
 
419
  transition: background 0.2s;
420
  }
421
 
@@ -423,6 +752,317 @@ body {
423
  background: #0056b3;
424
  }
425
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  /* ================ SETTINGS PAGE ================ */
427
 
428
  .settings-container {
@@ -501,12 +1141,13 @@ input[type="number"] {
501
  font-family: 'Nunito', sans-serif;
502
  }
503
 
 
504
  .setting-group .action-btn {
505
- display: inline-block;
506
- width: 48%;
507
- margin: 15px 1%;
508
- text-align: center;
509
- box-sizing: border-box;
510
  }
511
 
512
  #save-settings {
@@ -621,6 +1262,7 @@ details p {
621
  }
622
 
623
  /* ================ SESSION SUMMARY MODAL ================ */
 
624
  .modal-overlay {
625
  position: fixed;
626
  top: 0;
@@ -737,15 +1379,83 @@ details p {
737
  width: 90%;
738
  flex-direction: column;
739
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  }
741
- /* SESSION RESULT OVERLAY */
 
 
 
742
  .session-result-overlay {
743
  position: absolute;
744
  top: 0;
745
  left: 0;
746
  width: 100%;
747
  height: 100%;
748
- background-color: rgba(0, 0, 0, 0.85);
749
  display: flex;
750
  flex-direction: column;
751
  justify-content: center;
@@ -753,13 +1463,13 @@ details p {
753
  color: white;
754
  z-index: 10;
755
  animation: fadeIn 0.5s ease;
756
- backdrop-filter: blur(5px);
757
  }
758
 
759
  .session-result-overlay h3 {
760
  font-size: 32px;
761
  margin-bottom: 30px;
762
- color: #4cd137;
763
  text-transform: uppercase;
764
  letter-spacing: 2px;
765
  }
@@ -767,7 +1477,7 @@ details p {
767
  .session-result-overlay .result-item {
768
  display: flex;
769
  justify-content: space-between;
770
- width: 200px;
771
  margin-bottom: 15px;
772
  font-size: 20px;
773
  border-bottom: 1px solid rgba(255,255,255,0.2);
@@ -782,7 +1492,7 @@ details p {
782
  .session-result-overlay .value {
783
  color: #fff;
784
  font-weight: bold;
785
- font-family: 'Courier New', monospace;
786
  }
787
 
788
  @keyframes fadeIn {
@@ -790,7 +1500,7 @@ details p {
790
  to { opacity: 1; transform: scale(1); }
791
  }
792
 
793
- /* welcome modal */
794
  .welcome-modal-overlay {
795
  position: fixed;
796
  top: 0; left: 0; right: 0; bottom: 0;
@@ -814,18 +1524,12 @@ border: 1px solid #333;
814
  .welcome-modal p { margin-bottom: 30px; color: #ccc; }
815
  .welcome-buttons { display: flex; gap: 20px; justify-content: center; }
816
 
817
- /* top avatar */
818
- #top-menu {
819
- position: relative;
820
- display: flex;
821
- justify-content: center;
822
- align-items: center;
823
- }
824
-
825
  .avatar-container {
826
- position: absolute;
827
- left: 20px;
828
- cursor: pointer;
 
829
  }
830
 
831
  .avatar-circle {
@@ -844,30 +1548,45 @@ border: 2px solid transparent;
844
 
845
  .avatar-circle.user { background-color: #555; }
846
  .avatar-circle.admin { background-color: #ffaa00; border-color: #fff; box-shadow: 0 0 10px rgba(255, 170, 0, 0.5); }
847
- /* home 2x2 button grid */
848
  .home-button-grid {
849
  display: grid;
850
- grid-template-columns: 1fr 1fr;
851
- gap: 20px;
852
  width: 100%;
853
- max-width: 500px;
854
- margin: 40px auto 0 auto;
855
  }
856
 
857
  .home-button-grid .btn-main {
858
  width: 100%;
859
- height: 60px;
860
- margin: 0;
861
  padding: 10px;
862
  font-size: 1rem;
863
  display: flex;
864
  justify-content: center;
865
  align-items: center;
866
  text-align: center;
867
- box-sizing: border-box;
868
  }
869
 
 
870
  @media (max-width: 600px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
871
  .home-button-grid {
872
  gap: 15px;
873
  max-width: 90%;
@@ -877,4 +1596,4 @@ border: 2px solid transparent;
877
  height: 50px;
878
  font-size: 0.85rem;
879
  }
880
- }
 
40
  display: flex;
41
  align-items: center;
42
  justify-content: center; /* Center buttons horizontally */
43
+ gap: 0;
44
+ padding: 0 24px 0 76px;
45
+ box-sizing: border-box;
46
  box-shadow: 0 2px 5px rgba(0,0,0,0.05);
47
  position: fixed;
48
  top: 0;
49
+ left: 0;
50
+ right: 0;
51
  width: 100%;
52
  z-index: 1000;
53
+ overflow-x: auto;
54
+ overflow-y: hidden;
55
+ white-space: nowrap;
56
  }
57
 
58
  .menu-btn {
 
114
  /* PAGE A SPECIFIC */
115
  #page-a {
116
  justify-content: center; /* Center vertically */
117
+ /* Fine-tune this margin if the Home screen sits slightly too low. */
118
  margin-top: -40px;
119
+ flex: 1; /* Fill the remaining height so vertical centering still works. */
120
  }
121
 
122
  #page-a h1 {
123
  font-size: 80px;
124
  margin: 0 0 10px 0;
125
  color: #000;
126
+ text-align: center; /* Keep the heading centered. */
127
  }
128
 
129
  #page-a p {
 
153
  #page-b {
154
  justify-content: space-evenly; /* Distribute vertical space */
155
  padding-bottom: 20px;
156
+ min-height: calc(100vh - 60px); /* Ensure the page still fills the viewport. */
157
  }
158
 
159
  /* 1. Display Area */
160
  #display-area {
161
  width: 60%;
162
+ height: 50vh; /* Use viewport height to scale more consistently across screens. */
163
  min-height: 300px;
164
  border: 2px solid #ddd;
165
  border-radius: 12px;
 
170
  color: #555;
171
  font-size: 24px;
172
  position: relative;
173
+ /* Keep video content centered without overflowing the frame. */
174
  overflow: hidden;
175
  }
176
 
177
+ .focus-display-shell {
178
+ background: linear-gradient(180deg, #f7f5f2 0%, #f1f0ec 100%);
179
+ }
180
+
181
+ .focus-flow-overlay {
182
+ position: fixed;
183
+ top: 76px;
184
+ right: 20px;
185
+ bottom: 20px;
186
+ left: 20px;
187
+ display: flex;
188
+ align-items: center;
189
+ justify-content: center;
190
+ padding: 0;
191
+ background: rgba(17, 31, 52, 0.18);
192
+ backdrop-filter: blur(10px);
193
+ z-index: 900;
194
+ }
195
+
196
+ .focus-flow-card {
197
+ width: min(1040px, 100%);
198
+ background: #fff;
199
+ border-radius: 24px;
200
+ padding: 30px 34px;
201
+ box-shadow: 0 28px 80px rgba(14, 44, 88, 0.18);
202
+ border: 1px solid rgba(0, 123, 255, 0.12);
203
+ box-sizing: border-box;
204
+ }
205
+
206
+ .focus-flow-header {
207
+ display: flex;
208
+ align-items: center;
209
+ justify-content: space-between;
210
+ gap: 24px;
211
+ margin-bottom: 18px;
212
+ }
213
+
214
+ .focus-flow-eyebrow {
215
+ display: inline-block;
216
+ padding: 6px 12px;
217
+ border-radius: 999px;
218
+ background: #e7f3ff;
219
+ color: #007BFF;
220
+ font-size: 0.82rem;
221
+ font-weight: 800;
222
+ letter-spacing: 0.04em;
223
+ text-transform: uppercase;
224
+ }
225
+
226
+ .focus-flow-header h2 {
227
+ margin: 14px 0 0;
228
+ color: #333;
229
+ font-size: clamp(1.8rem, 2.5vw, 2.5rem);
230
+ line-height: 1.1;
231
+ }
232
+
233
+ .focus-flow-icon {
234
+ flex: 0 0 auto;
235
+ display: flex;
236
+ align-items: center;
237
+ justify-content: center;
238
+ width: 116px;
239
+ height: 116px;
240
+ border-radius: 24px;
241
+ background: linear-gradient(180deg, #f4f9ff 0%, #edf5ff 100%);
242
+ border: 1px solid rgba(0, 123, 255, 0.12);
243
+ }
244
+
245
+ .focus-flow-lead {
246
+ margin: 0 0 20px;
247
+ color: #4a4a4a;
248
+ font-size: 1rem;
249
+ line-height: 1.6;
250
+ }
251
+
252
+ .focus-flow-grid {
253
+ display: grid;
254
+ grid-template-columns: repeat(3, minmax(0, 1fr));
255
+ gap: 16px;
256
+ }
257
+
258
+ .focus-flow-panel {
259
+ background: #f8fbff;
260
+ border: 1px solid #d9eaff;
261
+ border-radius: 14px;
262
+ padding: 18px;
263
+ }
264
+
265
+ .focus-flow-panel h3,
266
+ .focus-flow-step-copy h3 {
267
+ margin: 0 0 8px;
268
+ color: #333;
269
+ font-size: 1rem;
270
+ }
271
+
272
+ .focus-flow-panel p,
273
+ .focus-flow-step-copy p {
274
+ margin: 0;
275
+ color: #5e6670;
276
+ font-size: 0.95rem;
277
+ line-height: 1.6;
278
+ }
279
+
280
+ .focus-flow-steps {
281
+ display: grid;
282
+ grid-template-columns: repeat(3, minmax(0, 1fr));
283
+ gap: 14px;
284
+ }
285
+
286
+ .focus-flow-step {
287
+ display: flex;
288
+ align-items: flex-start;
289
+ gap: 14px;
290
+ background: #f8fbff;
291
+ border: 1px solid #d9eaff;
292
+ border-radius: 14px;
293
+ padding: 16px 18px;
294
+ min-height: 100px;
295
+ box-sizing: border-box;
296
+ }
297
+
298
+ .focus-flow-step-number {
299
+ flex: 0 0 auto;
300
+ display: inline-flex;
301
+ align-items: center;
302
+ justify-content: center;
303
+ width: 34px;
304
+ height: 34px;
305
+ border-radius: 50%;
306
+ background: #007BFF;
307
+ color: #fff;
308
+ font-size: 0.95rem;
309
+ font-weight: 800;
310
+ }
311
+
312
+ .focus-flow-step-copy {
313
+ min-width: 0;
314
+ }
315
+
316
+ .focus-flow-footer {
317
+ display: flex;
318
+ align-items: center;
319
+ justify-content: space-between;
320
+ gap: 16px;
321
+ margin-top: 20px;
322
+ }
323
+
324
+ .focus-flow-note {
325
+ color: #667281;
326
+ font-size: 0.94rem;
327
+ line-height: 1.6;
328
+ }
329
+
330
+ .focus-flow-button,
331
+ .focus-flow-secondary {
332
+ border: none;
333
+ border-radius: 999px;
334
+ padding: 13px 24px;
335
+ font-family: 'Nunito', sans-serif;
336
+ font-size: 0.98rem;
337
+ font-weight: 800;
338
+ cursor: pointer;
339
+ transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
340
+ }
341
+
342
+ .focus-flow-button {
343
+ background: #007BFF;
344
+ color: #fff;
345
+ box-shadow: 0 12px 24px rgba(0, 123, 255, 0.18);
346
+ }
347
+
348
+ .focus-flow-button:hover {
349
+ background: #0069d9;
350
+ border-color: transparent;
351
+ transform: translateY(-1px);
352
+ }
353
+
354
+ .focus-flow-secondary {
355
+ background: #eef3f8;
356
+ color: #4b5a6b;
357
+ }
358
+
359
+ .focus-flow-secondary:hover {
360
+ background: #e2eaf3;
361
+ border-color: transparent;
362
+ }
363
+
364
+ .focus-state-pill {
365
+ position: absolute;
366
+ top: 18px;
367
+ left: 18px;
368
+ display: inline-flex;
369
+ align-items: center;
370
+ gap: 10px;
371
+ padding: 10px 16px;
372
+ border-radius: 999px;
373
+ color: #fff;
374
+ font-size: 0.88rem;
375
+ font-weight: 800;
376
+ letter-spacing: 0.04em;
377
+ text-transform: uppercase;
378
+ z-index: 2;
379
+ box-shadow: 0 12px 24px rgba(0, 0, 0, 0.18);
380
+ }
381
+
382
+ .focus-state-pill.pending {
383
+ background: rgba(87, 96, 111, 0.92);
384
+ }
385
+
386
+ .focus-state-pill.focused {
387
+ background: rgba(33, 163, 102, 0.94);
388
+ }
389
+
390
+ .focus-state-pill.not-focused {
391
+ background: rgba(215, 68, 68, 0.94);
392
+ }
393
+
394
+ .focus-state-dot {
395
+ width: 10px;
396
+ height: 10px;
397
+ border-radius: 50%;
398
+ background: currentColor;
399
+ box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.16);
400
+ }
401
+
402
+ .focus-idle-overlay {
403
+ position: absolute;
404
+ inset: 0;
405
+ display: flex;
406
+ flex-direction: column;
407
+ align-items: center;
408
+ justify-content: center;
409
+ gap: 10px;
410
+ background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12), rgba(0, 0, 0, 0.72));
411
+ color: #fff;
412
+ text-align: center;
413
+ z-index: 1;
414
+ }
415
+
416
+ .focus-idle-overlay p {
417
+ margin: 0;
418
+ font-size: 1.6rem;
419
+ font-weight: 800;
420
+ }
421
+
422
+ .focus-idle-overlay span {
423
+ max-width: 420px;
424
+ color: rgba(255, 255, 255, 0.82);
425
+ font-size: 0.98rem;
426
+ line-height: 1.5;
427
+ }
428
+
429
+ .focus-inline-error {
430
+ margin-top: 18px;
431
+ padding: 12px 16px;
432
+ max-width: 620px;
433
+ border-radius: 12px;
434
+ background: #fff1ee;
435
+ color: #b54028;
436
+ font-size: 0.95rem;
437
+ font-weight: 700;
438
+ box-shadow: 0 10px 20px rgba(181, 64, 40, 0.08);
439
+ }
440
+
441
+ .focus-inline-error-standalone {
442
+ width: 60%;
443
+ box-sizing: border-box;
444
+ }
445
+
446
+ .focus-debug-panel {
447
+ position: absolute;
448
+ top: 10px;
449
+ right: 10px;
450
+ background: rgba(0,0,0,0.7);
451
+ color: white;
452
+ padding: 10px;
453
+ border-radius: 5px;
454
+ font-size: 12px;
455
+ font-family: monospace;
456
+ }
457
+
458
+ .focus-model-strip {
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: center;
462
+ gap: 8px;
463
+ padding: 8px 16px;
464
+ background: #1a1a2e;
465
+ border-radius: 8px;
466
+ margin: 8px auto;
467
+ max-width: 600px;
468
+ }
469
+
470
+ .focus-model-label {
471
+ color: #aaa;
472
+ font-size: 13px;
473
+ margin-right: 4px;
474
+ }
475
+
476
+ .focus-model-button {
477
+ padding: 5px 14px;
478
+ border-radius: 16px;
479
+ border: 1px solid #555;
480
+ background: transparent;
481
+ color: #ccc;
482
+ font-size: 12px;
483
+ font-weight: 600;
484
+ text-transform: uppercase;
485
+ }
486
+
487
+ .focus-model-button.active {
488
+ border: 2px solid #007BFF;
489
+ background: #007BFF;
490
+ color: #fff;
491
+ }
492
+
493
  #display-area video {
494
  width: 100%;
495
  height: 100%;
496
+ object-fit: cover; /* Behaves similarly to background-size: cover. */
497
  }
498
 
499
  /* 2. Timeline Area */
 
691
  #focus-chart {
692
  display: block;
693
  margin: 0 auto;
694
+ /* Make sure the chart scales within its container. */
695
  max-width: 100%;
696
  }
697
 
 
736
  }
737
 
738
  .btn-view {
739
+ padding: 6px 18px;
740
  background: #007BFF;
741
  color: white;
742
  border: none;
743
+ border-radius: 999px;
744
  cursor: pointer;
745
  font-family: 'Nunito', sans-serif;
746
+ font-size: 12px;
747
+ font-weight: 700;
748
  transition: background 0.2s;
749
  }
750
 
 
752
  background: #0056b3;
753
  }
754
 
755
+ .records-detail-modal {
756
+ width: min(960px, 92vw);
757
+ max-width: 960px;
758
+ max-height: 86vh;
759
+ overflow-y: auto;
760
+ padding: 30px;
761
+ box-sizing: border-box;
762
+ }
763
+
764
+ .records-detail-header {
765
+ display: flex;
766
+ justify-content: space-between;
767
+ align-items: flex-start;
768
+ gap: 20px;
769
+ margin-bottom: 24px;
770
+ }
771
+
772
+ .records-detail-kicker {
773
+ color: #007BFF;
774
+ font-size: 12px;
775
+ font-weight: 800;
776
+ letter-spacing: 0.08em;
777
+ text-transform: uppercase;
778
+ }
779
+
780
+ .records-detail-header h2 {
781
+ margin: 10px 0 8px;
782
+ color: #333;
783
+ text-align: left;
784
+ }
785
+
786
+ .records-detail-subtitle {
787
+ margin: 0;
788
+ color: #667281;
789
+ line-height: 1.6;
790
+ }
791
+
792
+ .records-detail-close {
793
+ border: 1px solid #d6e6fa;
794
+ background: #f4f9ff;
795
+ color: #3569a8;
796
+ border-radius: 999px;
797
+ padding: 10px 18px;
798
+ font-family: 'Nunito', sans-serif;
799
+ font-weight: 700;
800
+ cursor: pointer;
801
+ }
802
+
803
+ .records-detail-close:hover {
804
+ border-color: #bfd9f7;
805
+ background: #e9f4ff;
806
+ }
807
+
808
+ .records-detail-feedback {
809
+ padding: 18px 20px;
810
+ border-radius: 14px;
811
+ background: #f7f9fc;
812
+ color: #516173;
813
+ font-weight: 700;
814
+ }
815
+
816
+ .records-detail-feedback-error {
817
+ background: #fff1ee;
818
+ color: #b54028;
819
+ }
820
+
821
+ .records-detail-summary {
822
+ display: grid;
823
+ grid-template-columns: repeat(4, minmax(0, 1fr));
824
+ gap: 14px;
825
+ margin-bottom: 18px;
826
+ }
827
+
828
+ .records-detail-stat {
829
+ padding: 18px;
830
+ border-radius: 14px;
831
+ background: #f8fbff;
832
+ border: 1px solid #d9eaff;
833
+ }
834
+
835
+ .records-detail-stat.excellent {
836
+ background: #eef9f0;
837
+ border-color: #cdebd3;
838
+ }
839
+
840
+ .records-detail-stat.good {
841
+ background: #fff9eb;
842
+ border-color: #f8e3a8;
843
+ }
844
+
845
+ .records-detail-stat.fair {
846
+ background: #fff4eb;
847
+ border-color: #ffd6af;
848
+ }
849
+
850
+ .records-detail-stat.low {
851
+ background: #fff0f0;
852
+ border-color: #f3c7c7;
853
+ }
854
+
855
+ .records-detail-stat-label {
856
+ display: block;
857
+ margin-bottom: 8px;
858
+ color: #667281;
859
+ font-size: 13px;
860
+ font-weight: 700;
861
+ text-transform: uppercase;
862
+ letter-spacing: 0.04em;
863
+ }
864
+
865
+ .records-detail-stat-value {
866
+ display: block;
867
+ color: #1f2d3d;
868
+ font-size: 28px;
869
+ line-height: 1.1;
870
+ }
871
+
872
+ .records-detail-grid {
873
+ display: grid;
874
+ grid-template-columns: repeat(2, minmax(0, 1fr));
875
+ gap: 16px;
876
+ margin-bottom: 16px;
877
+ }
878
+
879
+ .records-detail-card {
880
+ background: white;
881
+ border: 1px solid #e8eef5;
882
+ border-radius: 16px;
883
+ padding: 20px;
884
+ box-shadow: 0 8px 24px rgba(20, 44, 74, 0.06);
885
+ margin-bottom: 16px;
886
+ }
887
+
888
+ .records-detail-card:last-child {
889
+ margin-bottom: 0;
890
+ }
891
+
892
+ .records-detail-card h3 {
893
+ margin: 0 0 16px;
894
+ color: #333;
895
+ font-size: 18px;
896
+ }
897
+
898
+ .records-detail-list {
899
+ display: grid;
900
+ grid-template-columns: repeat(2, minmax(0, 1fr));
901
+ gap: 14px 18px;
902
+ }
903
+
904
+ .records-detail-item {
905
+ display: flex;
906
+ flex-direction: column;
907
+ gap: 6px;
908
+ }
909
+
910
+ .records-detail-item-label {
911
+ color: #7a8795;
912
+ font-size: 12px;
913
+ font-weight: 700;
914
+ text-transform: uppercase;
915
+ letter-spacing: 0.05em;
916
+ }
917
+
918
+ .records-detail-item-value {
919
+ color: #263445;
920
+ font-size: 15px;
921
+ font-weight: 700;
922
+ line-height: 1.5;
923
+ }
924
+
925
+ .records-detail-section-head {
926
+ display: flex;
927
+ align-items: center;
928
+ justify-content: space-between;
929
+ gap: 12px;
930
+ margin-bottom: 16px;
931
+ }
932
+
933
+ .records-detail-section-head span {
934
+ color: #7a8795;
935
+ font-size: 13px;
936
+ font-weight: 700;
937
+ }
938
+
939
+ .records-detail-timeline {
940
+ display: grid;
941
+ grid-template-columns: repeat(auto-fit, minmax(10px, 1fr));
942
+ gap: 5px;
943
+ }
944
+
945
+ .records-detail-segment {
946
+ height: 48px;
947
+ border-radius: 999px;
948
+ }
949
+
950
+ .records-detail-segment.focused {
951
+ background: linear-gradient(180deg, #3ab86a 0%, #23a057 100%);
952
+ }
953
+
954
+ .records-detail-segment.mixed {
955
+ background: linear-gradient(180deg, #f1b447 0%, #df9a1e 100%);
956
+ }
957
+
958
+ .records-detail-segment.distracted {
959
+ background: linear-gradient(180deg, #ec7d7d 0%, #d9534f 100%);
960
+ }
961
+
962
+ .records-detail-legend {
963
+ display: flex;
964
+ flex-wrap: wrap;
965
+ gap: 16px;
966
+ margin-top: 14px;
967
+ color: #667281;
968
+ font-size: 13px;
969
+ font-weight: 700;
970
+ }
971
+
972
+ .records-detail-legend span {
973
+ display: inline-flex;
974
+ align-items: center;
975
+ gap: 8px;
976
+ }
977
+
978
+ .records-detail-dot {
979
+ width: 10px;
980
+ height: 10px;
981
+ border-radius: 50%;
982
+ display: inline-block;
983
+ }
984
+
985
+ .records-detail-dot.focused {
986
+ background: #23a057;
987
+ }
988
+
989
+ .records-detail-dot.mixed {
990
+ background: #df9a1e;
991
+ }
992
+
993
+ .records-detail-dot.distracted {
994
+ background: #d9534f;
995
+ }
996
+
997
+ .records-detail-events {
998
+ display: grid;
999
+ gap: 10px;
1000
+ max-height: 280px;
1001
+ overflow-y: auto;
1002
+ }
1003
+
1004
+ .records-detail-event {
1005
+ display: grid;
1006
+ grid-template-columns: auto 1fr auto;
1007
+ align-items: center;
1008
+ gap: 12px;
1009
+ padding: 12px 14px;
1010
+ background: #f8fbff;
1011
+ border: 1px solid #e1edf9;
1012
+ border-radius: 14px;
1013
+ }
1014
+
1015
+ .records-detail-event-time {
1016
+ min-width: 52px;
1017
+ color: #3569a8;
1018
+ font-size: 13px;
1019
+ font-weight: 800;
1020
+ }
1021
+
1022
+ .records-detail-event-copy {
1023
+ min-width: 0;
1024
+ }
1025
+
1026
+ .records-detail-event-status {
1027
+ color: #243345;
1028
+ font-size: 14px;
1029
+ font-weight: 800;
1030
+ }
1031
+
1032
+ .records-detail-event-meta {
1033
+ margin-top: 4px;
1034
+ color: #6f7d8c;
1035
+ font-size: 12px;
1036
+ line-height: 1.5;
1037
+ }
1038
+
1039
+ .records-detail-event-badge {
1040
+ padding: 7px 12px;
1041
+ border-radius: 999px;
1042
+ font-size: 11px;
1043
+ font-weight: 800;
1044
+ letter-spacing: 0.04em;
1045
+ text-transform: uppercase;
1046
+ }
1047
+
1048
+ .records-detail-event-badge.focused {
1049
+ background: #eaf8ef;
1050
+ color: #1f8a4c;
1051
+ }
1052
+
1053
+ .records-detail-event-badge.distracted {
1054
+ background: #fff1f1;
1055
+ color: #c24c49;
1056
+ }
1057
+
1058
+ .records-detail-empty {
1059
+ padding: 16px 18px;
1060
+ border-radius: 14px;
1061
+ background: #f7f9fc;
1062
+ color: #708090;
1063
+ font-weight: 700;
1064
+ }
1065
+
1066
  /* ================ SETTINGS PAGE ================ */
1067
 
1068
  .settings-container {
 
1141
  font-family: 'Nunito', sans-serif;
1142
  }
1143
 
1144
+ /* Center the settings buttons and give them more width. */
1145
  .setting-group .action-btn {
1146
+ display: inline-block; /* Allow buttons to sit side by side. */
1147
+ width: 48%; /* Roughly half-width each, with a small gutter. */
1148
+ margin: 15px 1%; /* Vertical spacing plus horizontal separation. */
1149
+ text-align: center; /* Center the label text. */
1150
+ box-sizing: border-box; /* Prevent borders from forcing an early wrap. */
1151
  }
1152
 
1153
  #save-settings {
 
1262
  }
1263
 
1264
  /* ================ SESSION SUMMARY MODAL ================ */
1265
+ /* These modal styles can be reused for future overlays. */
1266
  .modal-overlay {
1267
  position: fixed;
1268
  top: 0;
 
1379
  width: 90%;
1380
  flex-direction: column;
1381
  }
1382
+
1383
+ .focus-inline-error-standalone {
1384
+ width: 90%;
1385
+ }
1386
+
1387
+ .focus-flow-overlay {
1388
+ top: 70px;
1389
+ right: 10px;
1390
+ bottom: 10px;
1391
+ left: 10px;
1392
+ }
1393
+
1394
+ .focus-flow-card {
1395
+ padding: 22px 20px;
1396
+ }
1397
+
1398
+ .focus-flow-header {
1399
+ flex-direction: column;
1400
+ align-items: flex-start;
1401
+ }
1402
+
1403
+ .focus-flow-icon {
1404
+ width: 92px;
1405
+ height: 92px;
1406
+ }
1407
+
1408
+ .focus-flow-grid {
1409
+ grid-template-columns: 1fr;
1410
+ }
1411
+
1412
+ .focus-flow-steps {
1413
+ grid-template-columns: 1fr;
1414
+ }
1415
+
1416
+ .focus-flow-footer {
1417
+ flex-direction: column;
1418
+ align-items: stretch;
1419
+ }
1420
+
1421
+ .focus-flow-button,
1422
+ .focus-flow-secondary {
1423
+ width: 100%;
1424
+ }
1425
+
1426
+ .records-detail-modal {
1427
+ width: 94vw;
1428
+ padding: 22px 18px;
1429
+ }
1430
+
1431
+ .records-detail-header,
1432
+ .records-detail-section-head {
1433
+ flex-direction: column;
1434
+ align-items: flex-start;
1435
+ }
1436
+
1437
+ .records-detail-summary,
1438
+ .records-detail-grid,
1439
+ .records-detail-list {
1440
+ grid-template-columns: 1fr;
1441
+ }
1442
+
1443
+ .records-detail-event {
1444
+ grid-template-columns: 1fr;
1445
+ align-items: flex-start;
1446
+ }
1447
  }
1448
+ /* =========================================
1449
+ SESSION RESULT OVERLAY
1450
+ ========================================= */
1451
+
1452
  .session-result-overlay {
1453
  position: absolute;
1454
  top: 0;
1455
  left: 0;
1456
  width: 100%;
1457
  height: 100%;
1458
+ background-color: rgba(0, 0, 0, 0.85); /* Dark semi-transparent backdrop. */
1459
  display: flex;
1460
  flex-direction: column;
1461
  justify-content: center;
 
1463
  color: white;
1464
  z-index: 10;
1465
  animation: fadeIn 0.5s ease;
1466
+ backdrop-filter: blur(5px); /* Optional background blur. */
1467
  }
1468
 
1469
  .session-result-overlay h3 {
1470
  font-size: 32px;
1471
  margin-bottom: 30px;
1472
+ color: #4cd137; /* Green title accent. */
1473
  text-transform: uppercase;
1474
  letter-spacing: 2px;
1475
  }
 
1477
  .session-result-overlay .result-item {
1478
  display: flex;
1479
  justify-content: space-between;
1480
+ width: 200px; /* Keep the stat row compact. */
1481
  margin-bottom: 15px;
1482
  font-size: 20px;
1483
  border-bottom: 1px solid rgba(255,255,255,0.2);
 
1492
  .session-result-overlay .value {
1493
  color: #fff;
1494
  font-weight: bold;
1495
+ font-family: 'Courier New', monospace; /* Give the values a data-like look. */
1496
  }
1497
 
1498
  @keyframes fadeIn {
 
1500
  to { opacity: 1; transform: scale(1); }
1501
  }
1502
 
1503
+ /* ================= Welcome modal styles ================= */
1504
  .welcome-modal-overlay {
1505
  position: fixed;
1506
  top: 0; left: 0; right: 0; bottom: 0;
 
1524
  .welcome-modal p { margin-bottom: 30px; color: #ccc; }
1525
  .welcome-buttons { display: flex; gap: 20px; justify-content: center; }
1526
 
1527
+ /* ================= Top-left avatar styles ================= */
 
 
 
 
 
 
 
1528
  .avatar-container {
1529
+ position: absolute;
1530
+ left: 20px;
1531
+ cursor: pointer;
1532
+ z-index: 1;
1533
  }
1534
 
1535
  .avatar-circle {
 
1548
 
1549
  .avatar-circle.user { background-color: #555; }
1550
  .avatar-circle.admin { background-color: #ffaa00; border-color: #fff; box-shadow: 0 0 10px rgba(255, 170, 0, 0.5); }
1551
+ /* ================= Home page 2x2 responsive button grid ================= */
1552
  .home-button-grid {
1553
  display: grid;
1554
+ grid-template-columns: 1fr 1fr; /* Force a clean two-column split. */
1555
+ gap: 20px; /* Spacing between buttons. */
1556
  width: 100%;
1557
+ max-width: 500px; /* Cap the width so the grid does not feel oversized. */
1558
+ margin: 40px auto 0 auto; /* Add top spacing and keep the grid centered. */
1559
  }
1560
 
1561
  .home-button-grid .btn-main {
1562
  width: 100%;
1563
+ height: 60px; /* Keep all tiles at the same height. */
1564
+ margin: 0; /* Remove default outer spacing. */
1565
  padding: 10px;
1566
  font-size: 1rem;
1567
  display: flex;
1568
  justify-content: center;
1569
  align-items: center;
1570
  text-align: center;
1571
+ box-sizing: border-box; /* Prevent padding and borders from breaking the grid. */
1572
  }
1573
 
1574
+ /* Mobile-only scaling for screens below 600px. */
1575
  @media (max-width: 600px) {
1576
+ #top-menu {
1577
+ justify-content: flex-start;
1578
+ padding: 0 12px 0 68px;
1579
+ }
1580
+
1581
+ .menu-btn {
1582
+ padding: 10px 14px;
1583
+ font-size: 0.92rem;
1584
+ }
1585
+
1586
+ .separator {
1587
+ margin: 0 2px;
1588
+ }
1589
+
1590
  .home-button-grid {
1591
  gap: 15px;
1592
  max-width: 90%;
 
1596
  height: 50px;
1597
  font-size: 0.85rem;
1598
  }
1599
+ }
src/App.jsx CHANGED
@@ -16,7 +16,7 @@ function App() {
16
  const [sessionResult, setSessionResult] = useState(null);
17
  const [role, setRole] = useState('user');
18
 
19
- //
20
  useEffect(() => {
21
  fetch('/api/history', { method: 'DELETE' }).catch(err => console.error(err));
22
 
@@ -37,7 +37,7 @@ function App() {
37
  };
38
  }, []);
39
 
40
- //
41
  const handleAvatarClick = () => {
42
  setActiveTab('home');
43
  };
@@ -52,7 +52,7 @@ function App() {
52
  </div>
53
 
54
  <button className={`menu-btn ${activeTab === 'focus' ? 'active' : ''}`} onClick={() => setActiveTab('focus')}>
55
- Start Focus {isSessionActive && <span style={{marginLeft: '8px', color: '#00FF00'}}>●</span>}
56
  </button>
57
  <div className="separator"></div>
58
 
@@ -76,7 +76,7 @@ function App() {
76
  </button>
77
  </nav>
78
 
79
- {/* pass state to Home */}
80
  {activeTab === 'home' && <Home setActiveTab={setActiveTab} role={role} setRole={setRole} />}
81
 
82
  <FocusPageLocal
@@ -84,6 +84,7 @@ function App() {
84
  sessionResult={sessionResult}
85
  setSessionResult={setSessionResult}
86
  isActive={activeTab === 'focus'}
 
87
  />
88
  {activeTab === 'achievement' && <Achievement />}
89
  {activeTab === 'records' && <Records />}
@@ -93,4 +94,4 @@ function App() {
93
  );
94
  }
95
 
96
- export default App;
 
16
  const [sessionResult, setSessionResult] = useState(null);
17
  const [role, setRole] = useState('user');
18
 
19
+ // Clear persisted history on the initial page load without showing a prompt.
20
  useEffect(() => {
21
  fetch('/api/history', { method: 'DELETE' }).catch(err => console.error(err));
22
 
 
37
  };
38
  }, []);
39
 
40
+ // Clicking the avatar always returns the user to the Home tab.
41
  const handleAvatarClick = () => {
42
  setActiveTab('home');
43
  };
 
52
  </div>
53
 
54
  <button className={`menu-btn ${activeTab === 'focus' ? 'active' : ''}`} onClick={() => setActiveTab('focus')}>
55
+ Start Focus {isSessionActive && <span style={{ marginLeft: '8px', color: '#00FF00' }}>●</span>}
56
  </button>
57
  <div className="separator"></div>
58
 
 
76
  </button>
77
  </nav>
78
 
79
+ {/* Pass the current role controls down to the Home view. */}
80
  {activeTab === 'home' && <Home setActiveTab={setActiveTab} role={role} setRole={setRole} />}
81
 
82
  <FocusPageLocal
 
84
  sessionResult={sessionResult}
85
  setSessionResult={setSessionResult}
86
  isActive={activeTab === 'focus'}
87
+ role={role}
88
  />
89
  {activeTab === 'achievement' && <Achievement />}
90
  {activeTab === 'records' && <Records />}
 
94
  );
95
  }
96
 
97
+ export default App;
src/components/Achievement.jsx CHANGED
@@ -11,6 +11,7 @@ function Achievement() {
11
  const [badges, setBadges] = useState([]);
12
  const [loading, setLoading] = useState(true);
13
 
 
14
  const formatTime = (seconds) => {
15
  const hours = Math.floor(seconds / 3600);
16
  const minutes = Math.floor((seconds % 3600) / 60);
@@ -18,6 +19,7 @@ function Achievement() {
18
  return `${minutes}m`;
19
  };
20
 
 
21
  useEffect(() => {
22
  fetch('/api/stats/summary')
23
  .then(res => res.json())
@@ -32,6 +34,7 @@ function Achievement() {
32
  });
33
  }, []);
34
 
 
35
  useEffect(() => {
36
  const fetchSystem = () => {
37
  fetch('/api/stats/system')
@@ -47,6 +50,7 @@ function Achievement() {
47
  const calculateBadges = (data) => {
48
  const earnedBadges = [];
49
 
 
50
  if (data.total_sessions >= 1) {
51
  earnedBadges.push({
52
  id: 'first-session',
@@ -57,6 +61,7 @@ function Achievement() {
57
  });
58
  }
59
 
 
60
  if (data.total_sessions >= 10) {
61
  earnedBadges.push({
62
  id: 'ten-sessions',
@@ -67,6 +72,7 @@ function Achievement() {
67
  });
68
  }
69
 
 
70
  if (data.total_sessions >= 50) {
71
  earnedBadges.push({
72
  id: 'fifty-sessions',
@@ -77,6 +83,7 @@ function Achievement() {
77
  });
78
  }
79
 
 
80
  if (data.avg_focus_score >= 0.8 && data.total_sessions >= 5) {
81
  earnedBadges.push({
82
  id: 'focus-master',
@@ -87,6 +94,7 @@ function Achievement() {
87
  });
88
  }
89
 
 
90
  if (data.streak_days >= 7) {
91
  earnedBadges.push({
92
  id: 'week-streak',
@@ -107,6 +115,7 @@ function Achievement() {
107
  });
108
  }
109
 
 
110
  if (data.total_focus_time >= 36000) {
111
  earnedBadges.push({
112
  id: 'ten-hours',
@@ -117,6 +126,7 @@ function Achievement() {
117
  });
118
  }
119
 
 
120
  const allBadges = [
121
  {
122
  id: 'first-session',
 
11
  const [badges, setBadges] = useState([]);
12
  const [loading, setLoading] = useState(true);
13
 
14
+ // Format total focus time for display.
15
  const formatTime = (seconds) => {
16
  const hours = Math.floor(seconds / 3600);
17
  const minutes = Math.floor((seconds % 3600) / 60);
 
19
  return `${minutes}m`;
20
  };
21
 
22
+ // Load summary statistics.
23
  useEffect(() => {
24
  fetch('/api/stats/summary')
25
  .then(res => res.json())
 
34
  });
35
  }, []);
36
 
37
+ // Derive unlocked badges from summary statistics.
38
  useEffect(() => {
39
  const fetchSystem = () => {
40
  fetch('/api/stats/system')
 
50
  const calculateBadges = (data) => {
51
  const earnedBadges = [];
52
 
53
+ // First-session badge
54
  if (data.total_sessions >= 1) {
55
  earnedBadges.push({
56
  id: 'first-session',
 
61
  });
62
  }
63
 
64
+ // 10-session badge
65
  if (data.total_sessions >= 10) {
66
  earnedBadges.push({
67
  id: 'ten-sessions',
 
72
  });
73
  }
74
 
75
+ // 50-session badge
76
  if (data.total_sessions >= 50) {
77
  earnedBadges.push({
78
  id: 'fifty-sessions',
 
83
  });
84
  }
85
 
86
+ // Focus Master badge (average focus score > 80%)
87
  if (data.avg_focus_score >= 0.8 && data.total_sessions >= 5) {
88
  earnedBadges.push({
89
  id: 'focus-master',
 
94
  });
95
  }
96
 
97
+ // Streak badges
98
  if (data.streak_days >= 7) {
99
  earnedBadges.push({
100
  id: 'week-streak',
 
115
  });
116
  }
117
 
118
+ // Total focus time badge (10+ hours)
119
  if (data.total_focus_time >= 36000) {
120
  earnedBadges.push({
121
  id: 'ten-hours',
 
126
  });
127
  }
128
 
129
+ // Full badge catalog, including locked examples
130
  const allBadges = [
131
  {
132
  id: 'first-session',
src/components/Customise.jsx CHANGED
@@ -6,10 +6,10 @@ function Customise() {
6
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);
7
  const [threshold, setThreshold] = useState(30);
8
 
9
- //
10
  const fileInputRef = useRef(null);
11
 
12
- //
13
  useEffect(() => {
14
  fetch('/api/settings')
15
  .then(res => res.json())
@@ -24,7 +24,7 @@ function Customise() {
24
  .catch(err => console.error("Failed to load settings", err));
25
  }, []);
26
 
27
- //
28
  const handleSave = async () => {
29
  const settings = {
30
  sensitivity: parseInt(sensitivity),
@@ -46,34 +46,34 @@ function Customise() {
46
  }
47
  };
48
 
49
- //
50
  const handleExport = async () => {
51
  try {
52
- //
53
  const response = await fetch('/api/sessions?filter=all');
54
  if (!response.ok) throw new Error("Failed to fetch data");
55
 
56
  const data = await response.json();
57
 
58
- //
59
  const jsonString = JSON.stringify(data, null, 2);
60
- //
61
  localStorage.setItem('focus_magic_backup', jsonString);
62
 
63
  const blob = new Blob([jsonString], { type: 'application/json' });
64
 
65
- //
66
  const url = URL.createObjectURL(blob);
67
  const link = document.createElement('a');
68
  link.href = url;
69
- //
70
  link.download = `focus-guard-backup-${new Date().toISOString().slice(0, 10)}.json`;
71
 
72
- //
73
  document.body.appendChild(link);
74
  link.click();
75
 
76
- //
77
  document.body.removeChild(link);
78
  URL.revokeObjectURL(url);
79
  } catch (error) {
@@ -82,12 +82,12 @@ function Customise() {
82
  }
83
  };
84
 
85
- //
86
  const triggerImport = () => {
87
  fileInputRef.current.click();
88
  };
89
 
90
- //
91
  const handleFileChange = async (event) => {
92
  const file = event.target.files[0];
93
  if (!file) return;
@@ -98,12 +98,12 @@ function Customise() {
98
  const content = e.target.result;
99
  const sessions = JSON.parse(content);
100
 
101
- //
102
  if (!Array.isArray(sessions)) {
103
  throw new Error("Invalid file format: Expected a list of sessions.");
104
  }
105
 
106
- //
107
  const response = await fetch('/api/import', {
108
  method: 'POST',
109
  headers: { 'Content-Type': 'application/json' },
@@ -119,13 +119,13 @@ function Customise() {
119
  } catch (err) {
120
  alert("Error parsing file: " + err.message);
121
  }
122
- //
123
  event.target.value = '';
124
  };
125
  reader.readAsText(file);
126
  };
127
 
128
- //
129
  const handleClearHistory = async () => {
130
  if (!window.confirm("Are you sure? This will delete ALL your session history permanently.")) {
131
  return;
@@ -187,7 +187,7 @@ function Customise() {
187
  <div className="setting-group">
188
  <h2>Data Management</h2>
189
 
190
- {/* hidden file input, json only */}
191
  <input
192
  type="file"
193
  ref={fileInputRef}
@@ -197,17 +197,17 @@ function Customise() {
197
  />
198
 
199
  <div style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap' }}>
200
- {/* Export */}
201
  <button id="export-data" className="action-btn blue" onClick={handleExport} style={{ width: '30%', minWidth: '120px' }}>
202
  Export Data
203
  </button>
204
 
205
- {/* Import */}
206
  <button id="import-data" className="action-btn yellow" onClick={triggerImport} style={{ width: '30%', minWidth: '120px' }}>
207
  Import Data
208
  </button>
209
 
210
- {/* Clear */}
211
  <button id="clear-history" className="action-btn red" onClick={handleClearHistory} style={{ width: '30%', minWidth: '120px' }}>
212
  Clear History
213
  </button>
 
6
  const [notificationsEnabled, setNotificationsEnabled] = useState(true);
7
  const [threshold, setThreshold] = useState(30);
8
 
9
+ // Reference to the hidden import input.
10
  const fileInputRef = useRef(null);
11
 
12
+ // 1. Load persisted settings.
13
  useEffect(() => {
14
  fetch('/api/settings')
15
  .then(res => res.json())
 
24
  .catch(err => console.error("Failed to load settings", err));
25
  }, []);
26
 
27
+ // 2. Save settings.
28
  const handleSave = async () => {
29
  const settings = {
30
  sensitivity: parseInt(sensitivity),
 
46
  }
47
  };
48
 
49
+ // 3. Export data.
50
  const handleExport = async () => {
51
  try {
52
+ // Fetch the full session history.
53
  const response = await fetch('/api/sessions?filter=all');
54
  if (!response.ok) throw new Error("Failed to fetch data");
55
 
56
  const data = await response.json();
57
 
58
+ // Build a JSON blob for download.
59
  const jsonString = JSON.stringify(data, null, 2);
60
+ // Keep a copy in local storage for quick recovery.
61
  localStorage.setItem('focus_magic_backup', jsonString);
62
 
63
  const blob = new Blob([jsonString], { type: 'application/json' });
64
 
65
+ // Create a temporary download link.
66
  const url = URL.createObjectURL(blob);
67
  const link = document.createElement('a');
68
  link.href = url;
69
+ // Include the current date in the export filename.
70
  link.download = `focus-guard-backup-${new Date().toISOString().slice(0, 10)}.json`;
71
 
72
+ // Trigger the browser download.
73
  document.body.appendChild(link);
74
  link.click();
75
 
76
+ // Clean up temporary elements and URLs.
77
  document.body.removeChild(link);
78
  URL.revokeObjectURL(url);
79
  } catch (error) {
 
82
  }
83
  };
84
 
85
+ // 4. Trigger the import file chooser.
86
  const triggerImport = () => {
87
  fileInputRef.current.click();
88
  };
89
 
90
+ // 5. Handle file import.
91
  const handleFileChange = async (event) => {
92
  const file = event.target.files[0];
93
  if (!file) return;
 
98
  const content = e.target.result;
99
  const sessions = JSON.parse(content);
100
 
101
+ // Basic validation: imported content must be an array.
102
  if (!Array.isArray(sessions)) {
103
  throw new Error("Invalid file format: Expected a list of sessions.");
104
  }
105
 
106
+ // Send the imported payload to the backend for storage.
107
  const response = await fetch('/api/import', {
108
  method: 'POST',
109
  headers: { 'Content-Type': 'application/json' },
 
119
  } catch (err) {
120
  alert("Error parsing file: " + err.message);
121
  }
122
+ // Reset the input so the same file can be selected again.
123
  event.target.value = '';
124
  };
125
  reader.readAsText(file);
126
  };
127
 
128
+ // 6. Clear all history.
129
  const handleClearHistory = async () => {
130
  if (!window.confirm("Are you sure? This will delete ALL your session history permanently.")) {
131
  return;
 
187
  <div className="setting-group">
188
  <h2>Data Management</h2>
189
 
190
+ {/* Hidden file input that only accepts JSON files. */}
191
  <input
192
  type="file"
193
  ref={fileInputRef}
 
197
  />
198
 
199
  <div style={{ display: 'flex', gap: '10px', justifyContent: 'center', flexWrap: 'wrap' }}>
200
+ {/* Export button */}
201
  <button id="export-data" className="action-btn blue" onClick={handleExport} style={{ width: '30%', minWidth: '120px' }}>
202
  Export Data
203
  </button>
204
 
205
+ {/* Import button */}
206
  <button id="import-data" className="action-btn yellow" onClick={triggerImport} style={{ width: '30%', minWidth: '120px' }}>
207
  Import Data
208
  </button>
209
 
210
+ {/* Clear button */}
211
  <button id="clear-history" className="action-btn red" onClick={handleClearHistory} style={{ width: '30%', minWidth: '120px' }}>
212
  Clear History
213
  </button>
src/components/FocusPage.jsx CHANGED
@@ -6,9 +6,9 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
6
 
7
  const videoRef = displayVideoRef;
8
 
9
- //
10
  const formatDuration = (seconds) => {
11
- //
12
  if (seconds === 0) return "0s";
13
 
14
  const mins = Math.floor(seconds / 60);
@@ -19,7 +19,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
19
  useEffect(() => {
20
  if (!videoManager) return;
21
 
22
- //
23
  const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
24
  videoManager.callbacks.onStatusUpdate = (isFocused) => {
25
  setTimelineEvents(prev => {
@@ -27,11 +27,11 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
27
  if (newEvents.length > 60) newEvents.shift();
28
  return newEvents;
29
  });
30
- //
31
  if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
32
  };
33
 
34
- //
35
  return () => {
36
  if (videoManager) {
37
  videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
@@ -42,7 +42,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
42
  const handleStart = async () => {
43
  try {
44
  if (videoManager) {
45
- setSessionResult(null);
46
  setTimelineEvents([]);
47
 
48
  console.log('🎬 Initializing camera...');
@@ -114,16 +114,16 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
114
  }
115
  };
116
 
117
- //
118
  const handleFloatingWindow = () => {
119
  handlePiP();
120
  };
121
 
122
  // ==========================================
123
- //
124
  // ==========================================
125
  const handlePreview = () => {
126
- //
127
  setSessionResult({
128
  duration_seconds: 0,
129
  focus_score: 0
@@ -155,7 +155,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
155
 
156
  return (
157
  <main id="page-b" className="page" style={pageStyle}>
158
- {/* 1. Camera / Display Area */}
159
  <section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}>
160
  <video
161
  ref={videoRef}
@@ -165,7 +165,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
165
  style={{ width: '100%', height: '100%', objectFit: 'contain' }}
166
  />
167
 
168
- {/* result overlay */}
169
  {sessionResult && (
170
  <div className="session-result-overlay">
171
  <h3>Session Complete!</h3>
@@ -178,7 +178,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
178
  <span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
179
  </div>
180
 
181
- {/* close preview */}
182
  <button
183
  onClick={handleCloseOverlay}
184
  style={{
@@ -198,7 +198,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
198
 
199
  </section>
200
 
201
- {/* 2. Timeline Area */}
202
  <section id="timeline-area">
203
  <div className="timeline-label">Timeline</div>
204
  <div id="timeline-visuals">
@@ -221,16 +221,16 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
221
  <div id="timeline-line"></div>
222
  </section>
223
 
224
- {/* 3. Control Buttons */}
225
  <section id="control-panel">
226
  <button id="btn-cam-start" className="action-btn green" onClick={handleStart}>Start</button>
227
  <button id="btn-floating" className="action-btn yellow" onClick={handleFloatingWindow}>Floating Window</button>
228
 
229
- {/* preview button */}
230
  <button
231
  id="btn-preview"
232
  className="action-btn"
233
- style={{ backgroundColor: '#6c5ce7' }}
234
  onClick={handlePreview}
235
  >
236
  Preview Result
@@ -239,7 +239,7 @@ function FocusPage({ videoManager, sessionResult, setSessionResult, isActive, di
239
  <button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>Stop</button>
240
  </section>
241
 
242
- {/* 4. Frame Control */}
243
  <section id="frame-control">
244
  <label htmlFor="frame-slider">Frame</label>
245
  <input
 
6
 
7
  const videoRef = displayVideoRef;
8
 
9
+ // Helper for formatting a duration in seconds.
10
  const formatDuration = (seconds) => {
11
+ // Show a compact zero state instead of "0m 0s".
12
  if (seconds === 0) return "0s";
13
 
14
  const mins = Math.floor(seconds / 60);
 
19
  useEffect(() => {
20
  if (!videoManager) return;
21
 
22
+ // Override the status callback so the timeline updates live.
23
  const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
24
  videoManager.callbacks.onStatusUpdate = (isFocused) => {
25
  setTimelineEvents(prev => {
 
27
  if (newEvents.length > 60) newEvents.shift();
28
  return newEvents;
29
  });
30
+ // Preserve the original callback if one was already registered.
31
  if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
32
  };
33
 
34
+ // Cleanup only restores callbacks and does not force-stop the session.
35
  return () => {
36
  if (videoManager) {
37
  videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
 
42
  const handleStart = async () => {
43
  try {
44
  if (videoManager) {
45
+ setSessionResult(null); // Clear any previous summary overlay before starting.
46
  setTimelineEvents([]);
47
 
48
  console.log('🎬 Initializing camera...');
 
114
  }
115
  };
116
 
117
+ // Floating window helper.
118
  const handleFloatingWindow = () => {
119
  handlePiP();
120
  };
121
 
122
  // ==========================================
123
+ // Preview button handler
124
  // ==========================================
125
  const handlePreview = () => {
126
+ // Inject placeholder data so the overlay can be previewed on demand.
127
  setSessionResult({
128
  duration_seconds: 0,
129
  focus_score: 0
 
155
 
156
  return (
157
  <main id="page-b" className="page" style={pageStyle}>
158
+ {/* 1. Camera / display area */}
159
  <section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}>
160
  <video
161
  ref={videoRef}
 
165
  style={{ width: '100%', height: '100%', objectFit: 'contain' }}
166
  />
167
 
168
+ {/* Session result overlay */}
169
  {sessionResult && (
170
  <div className="session-result-overlay">
171
  <h3>Session Complete!</h3>
 
178
  <span className="value">{(sessionResult.focus_score * 100).toFixed(1)}%</span>
179
  </div>
180
 
181
+ {/* Add a lightweight close button for preview mode. */}
182
  <button
183
  onClick={handleCloseOverlay}
184
  style={{
 
198
 
199
  </section>
200
 
201
+ {/* 2. Timeline area */}
202
  <section id="timeline-area">
203
  <div className="timeline-label">Timeline</div>
204
  <div id="timeline-visuals">
 
221
  <div id="timeline-line"></div>
222
  </section>
223
 
224
+ {/* 3. Control buttons */}
225
  <section id="control-panel">
226
  <button id="btn-cam-start" className="action-btn green" onClick={handleStart}>Start</button>
227
  <button id="btn-floating" className="action-btn yellow" onClick={handleFloatingWindow}>Floating Window</button>
228
 
229
+ {/* Temporarily repurpose the Models button as a preview action. */}
230
  <button
231
  id="btn-preview"
232
  className="action-btn"
233
+ style={{ backgroundColor: '#6c5ce7' }} // Use purple so the preview action stands out.
234
  onClick={handlePreview}
235
  >
236
  Preview Result
 
239
  <button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>Stop</button>
240
  </section>
241
 
242
+ {/* 4. Frame control */}
243
  <section id="frame-control">
244
  <label htmlFor="frame-slider">Frame</label>
245
  <input
src/components/FocusPageLocal.jsx CHANGED
@@ -1,38 +1,134 @@
1
  import React, { useState, useEffect, useRef } from 'react';
2
 
3
- function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActive }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  const [currentFrame, setCurrentFrame] = useState(15);
5
  const [timelineEvents, setTimelineEvents] = useState([]);
6
  const [stats, setStats] = useState(null);
7
  const [systemStats, setSystemStats] = useState(null);
8
  const [availableModels, setAvailableModels] = useState([]);
9
  const [currentModel, setCurrentModel] = useState('mlp');
 
 
 
 
 
10
 
11
  const localVideoRef = useRef(null);
12
  const displayCanvasRef = useRef(null);
13
  const pipVideoRef = useRef(null);
14
  const pipStreamRef = useRef(null);
 
15
 
16
  const formatDuration = (seconds) => {
17
- if (seconds === 0) return "0s";
18
  const mins = Math.floor(seconds / 60);
19
  const secs = Math.floor(seconds % 60);
20
  return `${mins}m ${secs}s`;
21
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  useEffect(() => {
24
  if (!videoManager) return;
25
 
26
  const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
 
 
27
  videoManager.callbacks.onStatusUpdate = (isFocused) => {
28
- setTimelineEvents(prev => {
29
  const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
30
  if (newEvents.length > 60) newEvents.shift();
31
  return newEvents;
32
  });
 
33
  if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
34
  };
35
 
 
 
 
 
 
 
36
  const statsInterval = setInterval(() => {
37
  if (videoManager && videoManager.getStats) {
38
  setStats(videoManager.getStats());
@@ -40,9 +136,8 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
40
  }, 1000);
41
 
42
  return () => {
43
- if (videoManager) {
44
- videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
45
- }
46
  clearInterval(statsInterval);
47
  };
48
  }, [videoManager]);
@@ -50,12 +145,40 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
50
  // Fetch available models on mount
51
  useEffect(() => {
52
  fetch('/api/models')
53
- .then(res => res.json())
54
- .then(data => {
55
  if (data.available) setAvailableModels(data.available);
56
  if (data.current) setCurrentModel(data.current);
57
  })
58
- .catch(err => console.error('Failed to fetch models:', err));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  }, []);
60
 
61
  // Poll server CPU/memory for UI
@@ -87,41 +210,50 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
87
  }
88
  };
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  const handleStart = async () => {
91
  try {
92
- if (videoManager) {
93
- setSessionResult(null);
94
- setTimelineEvents([]);
 
 
95
 
96
- console.log('Initializing local camera...');
97
  await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
98
- console.log('Camera initialized');
99
-
100
- console.log('Starting local streaming...');
101
- await videoManager.startStreaming();
102
- console.log('Streaming started successfully');
103
  }
 
 
104
  } catch (err) {
 
 
 
105
  console.error('Start error:', err);
106
- let errorMessage = "Failed to start: ";
107
-
108
- if (err.name === 'NotAllowedError') {
109
- errorMessage += "Camera permission denied. Please allow camera access.";
110
- } else if (err.name === 'NotFoundError') {
111
- errorMessage += "No camera found. Please connect a camera.";
112
- } else if (err.name === 'NotReadableError') {
113
- errorMessage += "Camera is already in use by another application.";
114
- } else {
115
- errorMessage += err.message || "Unknown error occurred.";
116
- }
117
-
118
- alert(errorMessage + "\n\nCheck browser console for details.");
119
  }
120
  };
121
 
122
  const handleStop = async () => {
123
  if (videoManager) {
124
- videoManager.stopStreaming();
125
  }
126
  try {
127
  if (document.pictureInPictureElement === pipVideoRef.current) {
@@ -133,9 +265,12 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
133
  pipVideoRef.current.srcObject = null;
134
  }
135
  if (pipStreamRef.current) {
136
- pipStreamRef.current.getTracks().forEach(t => t.stop());
137
  pipStreamRef.current = null;
138
  }
 
 
 
139
  };
140
 
141
  const handlePiP = async () => {
@@ -216,9 +351,7 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
216
 
217
  try {
218
  await pipVideo.play();
219
- } catch (_) {
220
- //
221
- }
222
 
223
  //
224
  if (isSafariPiP) {
@@ -252,7 +385,7 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
252
 
253
  } catch (err) {
254
  console.error('PiP error:', err);
255
- alert('Failed to enter Picture-in-Picture: ' + err.message);
256
  }
257
  };
258
 
@@ -261,7 +394,7 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
261
  };
262
 
263
  const handleFrameChange = (val) => {
264
- const rate = parseInt(val);
265
  setCurrentFrame(rate);
266
  if (videoManager) {
267
  videoManager.setFrameRate(rate);
@@ -314,24 +447,142 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
314
  pointerEvents: 'none'
315
  };
316
 
317
- useEffect(() => {
318
- return () => {
319
- if (pipVideoRef.current) {
320
- pipVideoRef.current.pause();
321
- pipVideoRef.current.srcObject = null;
322
- }
323
- if (pipStreamRef.current) {
324
- pipStreamRef.current.getTracks().forEach(t => t.stop());
325
- pipStreamRef.current = null;
326
- }
327
- };
328
- }, []);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
 
330
  return (
331
  <main id="page-b" className="page" style={pageStyle}>
332
- {/* 1. Camera / Display Area */}
333
- <section id="display-area" style={{ position: 'relative', overflow: 'hidden' }}>
334
- {/* hidden PiP video element */}
335
  <video
336
  ref={pipVideoRef}
337
  muted
@@ -363,11 +614,25 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
363
  width: '100%',
364
  height: '100%',
365
  objectFit: 'contain',
366
- backgroundColor: '#000'
367
  }}
368
  />
369
 
370
- {/* result overlay */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  {sessionResult && (
372
  <div className="session-result-overlay">
373
  <h3>Session Complete!</h3>
@@ -397,19 +662,8 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
397
  </div>
398
  )}
399
 
400
- {/* stats overlay */}
401
- {stats && stats.isStreaming && (
402
- <div style={{
403
- position: 'absolute',
404
- top: '10px',
405
- right: '10px',
406
- background: 'rgba(0,0,0,0.7)',
407
- color: 'white',
408
- padding: '10px',
409
- borderRadius: '5px',
410
- fontSize: '12px',
411
- fontFamily: 'monospace'
412
- }}>
413
  <div>Session: {stats.sessionId}</div>
414
  <div>Sent: {stats.framesSent}</div>
415
  <div>Processed: {stats.framesProcessed}</div>
@@ -423,7 +677,7 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
423
  </div>
424
  )}
425
  </div>
426
- )}
427
  </section>
428
 
429
  {/* Server CPU / Memory (always visible) */}
@@ -446,110 +700,93 @@ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActiv
446
  </section>
447
  )}
448
 
449
- {/* 2. Model Selector */}
450
- {availableModels.length > 0 && (
451
- <section style={{
452
- display: 'flex',
453
- alignItems: 'center',
454
- justifyContent: 'center',
455
- gap: '8px',
456
- padding: '8px 16px',
457
- background: '#1a1a2e',
458
- borderRadius: '8px',
459
- margin: '8px auto',
460
- maxWidth: '600px'
461
- }}>
462
- <span style={{ color: '#aaa', fontSize: '13px', marginRight: '4px' }}>Model:</span>
463
- {availableModels.map(name => (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  <button
465
- key={name}
466
- onClick={() => handleModelChange(name)}
467
- style={{
468
- padding: '5px 14px',
469
- borderRadius: '16px',
470
- border: currentModel === name ? '2px solid #007BFF' : '1px solid #555',
471
- background: currentModel === name ? '#007BFF' : 'transparent',
472
- color: currentModel === name ? '#fff' : '#ccc',
473
- fontSize: '12px',
474
- fontWeight: currentModel === name ? 'bold' : 'normal',
475
- cursor: 'pointer',
476
- textTransform: 'uppercase',
477
- transition: 'all 0.2s'
478
- }}
479
  >
480
- {name}
481
  </button>
482
- ))}
483
- </section>
484
- )}
485
-
486
- {/* 3. Timeline Area */}
487
- <section id="timeline-area">
488
- <div className="timeline-label">Timeline</div>
489
- <div id="timeline-visuals">
490
- {timelineEvents.map((event, index) => (
491
- <div
492
- key={index}
493
- className="timeline-block"
494
- style={{
495
- backgroundColor: event.isFocused ? '#00FF00' : '#FF0000',
496
- width: '10px',
497
- height: '20px',
498
- display: 'inline-block',
499
- marginRight: '2px',
500
- borderRadius: '2px'
501
- }}
502
- title={event.isFocused ? 'Focused' : 'Distracted'}
503
- />
504
- ))}
505
- </div>
506
- <div id="timeline-line"></div>
507
- </section>
508
 
509
- {/* 4. Control Buttons */}
510
- <section id="control-panel">
511
- <button id="btn-cam-start" className="action-btn green" onClick={handleStart}>
512
- Start
513
- </button>
514
-
515
- <button id="btn-floating" className="action-btn yellow" onClick={handleFloatingWindow}>
516
- Floating Window
517
- </button>
518
-
519
- <button
520
- id="btn-preview"
521
- className="action-btn"
522
- style={{ backgroundColor: '#6c5ce7' }}
523
- onClick={handlePreview}
524
- >
525
- Preview Result
526
- </button>
527
-
528
- <button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>
529
- Stop
530
- </button>
531
- </section>
532
 
533
- {/* 5. Frame Control */}
534
- <section id="frame-control">
535
- <label htmlFor="frame-slider">Frame Rate (FPS)</label>
536
- <input
537
- type="range"
538
- id="frame-slider"
539
- min="10"
540
- max="30"
541
- value={currentFrame}
542
- onChange={(e) => handleFrameChange(e.target.value)}
543
- />
544
- <input
545
- type="number"
546
- id="frame-input"
547
- min="10"
548
- max="30"
549
- value={currentFrame}
550
- onChange={(e) => handleFrameChange(e.target.value)}
551
- />
552
- </section>
 
 
 
 
 
 
 
 
 
 
553
  </main>
554
  );
555
  }
 
1
  import React, { useState, useEffect, useRef } from 'react';
2
 
3
+ const FLOW_STEPS = {
4
+ intro: 'intro',
5
+ permission: 'permission',
6
+ ready: 'ready'
7
+ };
8
+
9
+ const FOCUS_STATES = {
10
+ pending: 'pending',
11
+ focused: 'focused',
12
+ notFocused: 'not-focused'
13
+ };
14
+
15
+ function HelloIcon() {
16
+ return (
17
+ <svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">
18
+ <circle cx="48" cy="48" r="40" fill="#007BFF" />
19
+ <path d="M30 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
20
+ <path d="M54 38c0-4 2.7-7 6-7s6 3 6 7" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
21
+ <path d="M30 52c3 11 10 17 18 17s15-6 18-17" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" />
22
+ </svg>
23
+ );
24
+ }
25
+
26
+ function CameraIcon() {
27
+ return (
28
+ <svg width="110" height="110" viewBox="0 0 110 110" aria-hidden="true">
29
+ <rect x="30" y="36" width="50" height="34" rx="5" fill="none" stroke="#007BFF" strokeWidth="6" />
30
+ <path d="M24 72h62c0 9-7 16-16 16H40c-9 0-16-7-16-16Z" fill="none" stroke="#007BFF" strokeWidth="6" />
31
+ <path d="M55 28v8" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
32
+ <circle cx="55" cy="36" r="14" fill="none" stroke="#007BFF" strokeWidth="6" />
33
+ <circle cx="55" cy="36" r="4" fill="#007BFF" />
34
+ <path d="M46 83h18" stroke="#007BFF" strokeWidth="6" strokeLinecap="round" />
35
+ </svg>
36
+ );
37
+ }
38
+
39
+ function FocusPageLocal({ videoManager, sessionResult, setSessionResult, isActive, role }) {
40
  const [currentFrame, setCurrentFrame] = useState(15);
41
  const [timelineEvents, setTimelineEvents] = useState([]);
42
  const [stats, setStats] = useState(null);
43
  const [systemStats, setSystemStats] = useState(null);
44
  const [availableModels, setAvailableModels] = useState([]);
45
  const [currentModel, setCurrentModel] = useState('mlp');
46
+ const [flowStep, setFlowStep] = useState(FLOW_STEPS.intro);
47
+ const [cameraReady, setCameraReady] = useState(false);
48
+ const [isStarting, setIsStarting] = useState(false);
49
+ const [focusState, setFocusState] = useState(FOCUS_STATES.pending);
50
+ const [cameraError, setCameraError] = useState('');
51
 
52
  const localVideoRef = useRef(null);
53
  const displayCanvasRef = useRef(null);
54
  const pipVideoRef = useRef(null);
55
  const pipStreamRef = useRef(null);
56
+ const previewFrameRef = useRef(null);
57
 
58
  const formatDuration = (seconds) => {
59
+ if (seconds === 0) return '0s';
60
  const mins = Math.floor(seconds / 60);
61
  const secs = Math.floor(seconds % 60);
62
  return `${mins}m ${secs}s`;
63
  };
64
+
65
+ const stopPreviewLoop = () => {
66
+ if (previewFrameRef.current) {
67
+ cancelAnimationFrame(previewFrameRef.current);
68
+ previewFrameRef.current = null;
69
+ }
70
+ };
71
+
72
+ const startPreviewLoop = () => {
73
+ stopPreviewLoop();
74
+ const renderPreview = () => {
75
+ const canvas = displayCanvasRef.current;
76
+ const video = localVideoRef.current;
77
+
78
+ if (!canvas || !video || !cameraReady || videoManager?.isStreaming) {
79
+ previewFrameRef.current = null;
80
+ return;
81
+ }
82
+
83
+ if (video.readyState >= 2) {
84
+ const ctx = canvas.getContext('2d');
85
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
86
+ }
87
+
88
+ previewFrameRef.current = requestAnimationFrame(renderPreview);
89
+ };
90
+
91
+ previewFrameRef.current = requestAnimationFrame(renderPreview);
92
+ };
93
+
94
+ const getErrorMessage = (err) => {
95
+ if (err?.name === 'NotAllowedError') {
96
+ return 'Camera permission denied. Please allow camera access.';
97
+ }
98
+ if (err?.name === 'NotFoundError') {
99
+ return 'No camera found. Please connect a camera.';
100
+ }
101
+ if (err?.name === 'NotReadableError') {
102
+ return 'Camera is already in use by another application.';
103
+ }
104
+ if (err?.target?.url) {
105
+ return `WebSocket connection failed: ${err.target.url}. Check that the backend server is running.`;
106
+ }
107
+ return err?.message || 'Failed to start focus session.';
108
+ };
109
 
110
  useEffect(() => {
111
  if (!videoManager) return;
112
 
113
  const originalOnStatusUpdate = videoManager.callbacks.onStatusUpdate;
114
+ const originalOnSessionEnd = videoManager.callbacks.onSessionEnd;
115
+
116
  videoManager.callbacks.onStatusUpdate = (isFocused) => {
117
+ setTimelineEvents((prev) => {
118
  const newEvents = [...prev, { isFocused, timestamp: Date.now() }];
119
  if (newEvents.length > 60) newEvents.shift();
120
  return newEvents;
121
  });
122
+ setFocusState(isFocused ? FOCUS_STATES.focused : FOCUS_STATES.notFocused);
123
  if (originalOnStatusUpdate) originalOnStatusUpdate(isFocused);
124
  };
125
 
126
+ videoManager.callbacks.onSessionEnd = (summary) => {
127
+ setFocusState(FOCUS_STATES.pending);
128
+ setCameraReady(false);
129
+ if (originalOnSessionEnd) originalOnSessionEnd(summary);
130
+ };
131
+
132
  const statsInterval = setInterval(() => {
133
  if (videoManager && videoManager.getStats) {
134
  setStats(videoManager.getStats());
 
136
  }, 1000);
137
 
138
  return () => {
139
+ videoManager.callbacks.onStatusUpdate = originalOnStatusUpdate;
140
+ videoManager.callbacks.onSessionEnd = originalOnSessionEnd;
 
141
  clearInterval(statsInterval);
142
  };
143
  }, [videoManager]);
 
145
  // Fetch available models on mount
146
  useEffect(() => {
147
  fetch('/api/models')
148
+ .then((res) => res.json())
149
+ .then((data) => {
150
  if (data.available) setAvailableModels(data.available);
151
  if (data.current) setCurrentModel(data.current);
152
  })
153
+ .catch((err) => console.error('Failed to fetch models:', err));
154
+ }, []);
155
+
156
+ useEffect(() => {
157
+ if (flowStep === FLOW_STEPS.ready && cameraReady && !videoManager?.isStreaming) {
158
+ startPreviewLoop();
159
+ return;
160
+ }
161
+ stopPreviewLoop();
162
+ }, [cameraReady, flowStep, videoManager?.isStreaming]);
163
+
164
+ useEffect(() => {
165
+ if (!isActive) {
166
+ stopPreviewLoop();
167
+ }
168
+ }, [isActive]);
169
+
170
+ useEffect(() => {
171
+ return () => {
172
+ stopPreviewLoop();
173
+ if (pipVideoRef.current) {
174
+ pipVideoRef.current.pause();
175
+ pipVideoRef.current.srcObject = null;
176
+ }
177
+ if (pipStreamRef.current) {
178
+ pipStreamRef.current.getTracks().forEach((t) => t.stop());
179
+ pipStreamRef.current = null;
180
+ }
181
+ };
182
  }, []);
183
 
184
  // Poll server CPU/memory for UI
 
210
  }
211
  };
212
 
213
+ const handleEnableCamera = async () => {
214
+ if (!videoManager) return;
215
+
216
+ try {
217
+ setCameraError('');
218
+ await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
219
+ setCameraReady(true);
220
+ setFlowStep(FLOW_STEPS.ready);
221
+ setFocusState(FOCUS_STATES.pending);
222
+ } catch (err) {
223
+ const errorMessage = getErrorMessage(err);
224
+ setCameraError(errorMessage);
225
+ console.error('Camera init error:', err);
226
+ }
227
+ };
228
  const handleStart = async () => {
229
  try {
230
+ setIsStarting(true);
231
+ setSessionResult(null);
232
+ setTimelineEvents([]);
233
+ setFocusState(FOCUS_STATES.pending);
234
+ setCameraError('');
235
 
236
+ if (!cameraReady) {
237
  await videoManager.initCamera(localVideoRef.current, displayCanvasRef.current);
238
+ setCameraReady(true);
239
+ setFlowStep(FLOW_STEPS.ready);
 
 
 
240
  }
241
+
242
+ await videoManager.startStreaming();
243
  } catch (err) {
244
+ const errorMessage = getErrorMessage(err);
245
+ setCameraError(errorMessage);
246
+ setFocusState(FOCUS_STATES.pending);
247
  console.error('Start error:', err);
248
+ alert(`Failed to start: ${errorMessage}\n\nCheck browser console for details.`);
249
+ } finally {
250
+ setIsStarting(false);
 
 
 
 
 
 
 
 
 
 
251
  }
252
  };
253
 
254
  const handleStop = async () => {
255
  if (videoManager) {
256
+ await videoManager.stopStreaming();
257
  }
258
  try {
259
  if (document.pictureInPictureElement === pipVideoRef.current) {
 
265
  pipVideoRef.current.srcObject = null;
266
  }
267
  if (pipStreamRef.current) {
268
+ pipStreamRef.current.getTracks().forEach((t) => t.stop());
269
  pipStreamRef.current = null;
270
  }
271
+ stopPreviewLoop();
272
+ setFocusState(FOCUS_STATES.pending);
273
+ setCameraReady(false);
274
  };
275
 
276
  const handlePiP = async () => {
 
351
 
352
  try {
353
  await pipVideo.play();
354
+ } catch (_) {}
 
 
355
 
356
  //
357
  if (isSafariPiP) {
 
385
 
386
  } catch (err) {
387
  console.error('PiP error:', err);
388
+ alert(`Failed to enter Picture-in-Picture: ${err.message}`);
389
  }
390
  };
391
 
 
394
  };
395
 
396
  const handleFrameChange = (val) => {
397
+ const rate = parseInt(val, 10);
398
  setCurrentFrame(rate);
399
  if (videoManager) {
400
  videoManager.setFrameRate(rate);
 
447
  pointerEvents: 'none'
448
  };
449
 
450
+ const focusStateLabel = {
451
+ [FOCUS_STATES.pending]: 'Pending',
452
+ [FOCUS_STATES.focused]: 'Focused',
453
+ [FOCUS_STATES.notFocused]: 'Not Focused'
454
+ }[focusState];
455
+
456
+ const introHighlights = [
457
+ {
458
+ title: 'Live focus tracking',
459
+ text: 'Head pose, gaze, and eye openness are read continuously during the session.'
460
+ },
461
+ {
462
+ title: 'Quick setup',
463
+ text: 'Front-facing light and a stable camera angle give the cleanest preview.'
464
+ },
465
+ {
466
+ title: 'Private by default',
467
+ text: 'Only session metadata is stored, not the raw camera footage.'
468
+ }
469
+ ];
470
+
471
+ const permissionSteps = [
472
+ {
473
+ title: 'Allow browser access',
474
+ text: 'Approve the camera prompt so the preview can appear immediately.'
475
+ },
476
+ {
477
+ title: 'Check your framing',
478
+ text: 'Keep your face visible and centered for more stable landmark detection.'
479
+ },
480
+ {
481
+ title: 'Start when ready',
482
+ text: 'After the preview appears, use the page controls to begin or stop.'
483
+ }
484
+ ];
485
+
486
+ const renderIntroCard = () => {
487
+ if (flowStep === FLOW_STEPS.intro) {
488
+ return (
489
+ <div className="focus-flow-overlay">
490
+ <div className="focus-flow-card">
491
+ <div className="focus-flow-header">
492
+ <div>
493
+ <div className="focus-flow-eyebrow">Focus Session</div>
494
+ <h2>Before you begin</h2>
495
+ </div>
496
+ <div className="focus-flow-icon">
497
+ <HelloIcon />
498
+ </div>
499
+ </div>
500
+
501
+ <p className="focus-flow-lead">
502
+ The focus page uses your live camera preview to estimate attention in real time.
503
+ Review the setup notes below, then continue to camera access.
504
+ </p>
505
+
506
+ <div className="focus-flow-grid">
507
+ {introHighlights.map((item) => (
508
+ <article key={item.title} className="focus-flow-panel">
509
+ <h3>{item.title}</h3>
510
+ <p>{item.text}</p>
511
+ </article>
512
+ ))}
513
+ </div>
514
+
515
+ <div className="focus-flow-footer">
516
+ <div className="focus-flow-note">
517
+ You can still change frame rate and available model options after the preview loads.
518
+ </div>
519
+ <button className="focus-flow-button" onClick={() => setFlowStep(FLOW_STEPS.permission)}>
520
+ Continue
521
+ </button>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ );
526
+ }
527
+
528
+ if (flowStep === FLOW_STEPS.permission && !cameraReady) {
529
+ return (
530
+ <div className="focus-flow-overlay">
531
+ <div className="focus-flow-card">
532
+ <div className="focus-flow-header">
533
+ <div>
534
+ <div className="focus-flow-eyebrow">Camera Setup</div>
535
+ <h2>Enable camera access</h2>
536
+ </div>
537
+ <div className="focus-flow-icon">
538
+ <CameraIcon />
539
+ </div>
540
+ </div>
541
+
542
+ <p className="focus-flow-lead">
543
+ Once access is granted, your preview appears here and the rest of the Focus page
544
+ behaves like the other dashboard screens.
545
+ </p>
546
+
547
+ <div className="focus-flow-steps">
548
+ {permissionSteps.map((item, index) => (
549
+ <div key={item.title} className="focus-flow-step">
550
+ <div className="focus-flow-step-number">{index + 1}</div>
551
+ <div className="focus-flow-step-copy">
552
+ <h3>{item.title}</h3>
553
+ <p>{item.text}</p>
554
+ </div>
555
+ </div>
556
+ ))}
557
+ </div>
558
+
559
+ {cameraError ? <div className="focus-inline-error">{cameraError}</div> : null}
560
+
561
+ <div className="focus-flow-footer">
562
+ <button
563
+ type="button"
564
+ className="focus-flow-secondary"
565
+ onClick={() => setFlowStep(FLOW_STEPS.intro)}
566
+ >
567
+ Back
568
+ </button>
569
+ <button className="focus-flow-button" onClick={handleEnableCamera}>
570
+ Enable Camera
571
+ </button>
572
+ </div>
573
+ </div>
574
+ </div>
575
+ );
576
+ }
577
+
578
+ return null;
579
+ };
580
 
581
  return (
582
  <main id="page-b" className="page" style={pageStyle}>
583
+ {renderIntroCard()}
584
+
585
+ <section id="display-area" className="focus-display-shell">
586
  <video
587
  ref={pipVideoRef}
588
  muted
 
614
  width: '100%',
615
  height: '100%',
616
  objectFit: 'contain',
617
+ backgroundColor: '#101010'
618
  }}
619
  />
620
 
621
+ {flowStep === FLOW_STEPS.ready ? (
622
+ <>
623
+ <div className={`focus-state-pill ${focusState}`}>
624
+ <span className="focus-state-dot" />
625
+ {focusStateLabel}
626
+ </div>
627
+ {!cameraReady && !videoManager?.isStreaming ? (
628
+ <div className="focus-idle-overlay">
629
+ <p>Camera is paused.</p>
630
+ <span>Use Start to enable the camera and begin detection.</span>
631
+ </div>
632
+ ) : null}
633
+ </>
634
+ ) : null}
635
+
636
  {sessionResult && (
637
  <div className="session-result-overlay">
638
  <h3>Session Complete!</h3>
 
662
  </div>
663
  )}
664
 
665
+ {role === 'admin' && stats && stats.isStreaming ? (
666
+ <div className="focus-debug-panel">
 
 
 
 
 
 
 
 
 
 
 
667
  <div>Session: {stats.sessionId}</div>
668
  <div>Sent: {stats.framesSent}</div>
669
  <div>Processed: {stats.framesProcessed}</div>
 
677
  </div>
678
  )}
679
  </div>
680
+ ) : null}
681
  </section>
682
 
683
  {/* Server CPU / Memory (always visible) */}
 
700
  </section>
701
  )}
702
 
703
+ {flowStep === FLOW_STEPS.ready ? (
704
+ <>
705
+ {availableModels.length > 0 ? (
706
+ <section className="focus-model-strip">
707
+ <span className="focus-model-label">Model:</span>
708
+ {availableModels.map((name) => (
709
+ <button
710
+ key={name}
711
+ onClick={() => handleModelChange(name)}
712
+ className={`focus-model-button ${currentModel === name ? 'active' : ''}`}
713
+ >
714
+ {name}
715
+ </button>
716
+ ))}
717
+ </section>
718
+ ) : null}
719
+
720
+ <section id="timeline-area">
721
+ <div className="timeline-label">Timeline</div>
722
+ <div id="timeline-visuals">
723
+ {timelineEvents.map((event, index) => (
724
+ <div
725
+ key={index}
726
+ className="timeline-block"
727
+ style={{
728
+ backgroundColor: event.isFocused ? '#00FF00' : '#FF0000',
729
+ width: '10px',
730
+ height: '20px',
731
+ display: 'inline-block',
732
+ marginRight: '2px',
733
+ borderRadius: '2px'
734
+ }}
735
+ title={event.isFocused ? 'Focused' : 'Distracted'}
736
+ />
737
+ ))}
738
+ </div>
739
+ <div id="timeline-line" />
740
+ </section>
741
+
742
+ <section id="control-panel">
743
+ <button id="btn-cam-start" className="action-btn green" onClick={handleStart} disabled={isStarting}>
744
+ {isStarting ? 'Starting...' : 'Start'}
745
+ </button>
746
+
747
+ <button id="btn-floating" className="action-btn yellow" onClick={handlePiP}>
748
+ Floating Window
749
+ </button>
750
  <button
751
+ id="btn-preview"
752
+ className="action-btn"
753
+ style={{ backgroundColor: '#ff7a52' }}
754
+ onClick={handlePreview}
 
 
 
 
 
 
 
 
 
 
755
  >
756
+ Preview Result
757
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
758
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
759
 
760
+ <button id="btn-cam-stop" className="action-btn red" onClick={handleStop}>
761
+ Stop
762
+ </button>
763
+ </section>
764
+
765
+ {cameraError ? (
766
+ <div className="focus-inline-error focus-inline-error-standalone">{cameraError}</div>
767
+ ) : null}
768
+
769
+ <section id="frame-control">
770
+ <label htmlFor="frame-slider">Frame Rate (FPS)</label>
771
+ <input
772
+ type="range"
773
+ id="frame-slider"
774
+ min="10"
775
+ max="30"
776
+ value={currentFrame}
777
+ onChange={(e) => handleFrameChange(e.target.value)}
778
+ />
779
+ <input
780
+ type="number"
781
+ id="frame-input"
782
+ min="10"
783
+ max="30"
784
+ value={currentFrame}
785
+ onChange={(e) => handleFrameChange(e.target.value)}
786
+ />
787
+ </section>
788
+ </>
789
+ ) : null}
790
  </main>
791
  );
792
  }
src/components/Home.jsx CHANGED
@@ -3,13 +3,13 @@ import React, { useRef } from 'react';
3
  function Home({ setActiveTab, role, setRole }) {
4
  const fileInputRef = useRef(null);
5
 
6
- //
7
  const handleNewStart = async () => {
8
  await fetch('/api/history', { method: 'DELETE' });
9
  setActiveTab('focus');
10
  };
11
 
12
- //
13
  const handleAutoImport = async () => {
14
  const backup = localStorage.getItem('focus_magic_backup');
15
  if (backup) {
@@ -33,7 +33,7 @@ function Home({ setActiveTab, role, setRole }) {
33
  }
34
  };
35
 
36
- //
37
  const handleFileChange = async (event) => {
38
  const file = event.target.files[0];
39
  if (!file) return;
@@ -57,7 +57,7 @@ function Home({ setActiveTab, role, setRole }) {
57
  reader.readAsText(file);
58
  };
59
 
60
- //
61
  const handleAdminToggle = async () => {
62
  if (role === 'admin') {
63
  if (window.confirm("Switch back to User mode? Current data will be cleared.")) {
@@ -96,10 +96,10 @@ function Home({ setActiveTab, role, setRole }) {
96
  <h1>FocusGuard</h1>
97
  <p>Your productivity monitor assistant.</p>
98
 
99
- {/* hidden file input outside grid */}
100
  <input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileChange} />
101
 
102
- {/* 2x2 button grid */}
103
  <div className="home-button-grid">
104
 
105
  <button className="btn-main" onClick={handleNewStart}>
@@ -123,4 +123,4 @@ function Home({ setActiveTab, role, setRole }) {
123
  );
124
  }
125
 
126
- export default Home;
 
3
  function Home({ setActiveTab, role, setRole }) {
4
  const fileInputRef = useRef(null);
5
 
6
+ // 1. Start a fresh focus workflow.
7
  const handleNewStart = async () => {
8
  await fetch('/api/history', { method: 'DELETE' });
9
  setActiveTab('focus');
10
  };
11
 
12
+ // 2. Restore a backup automatically from local storage.
13
  const handleAutoImport = async () => {
14
  const backup = localStorage.getItem('focus_magic_backup');
15
  if (backup) {
 
33
  }
34
  };
35
 
36
+ // 3. Import a backup file manually.
37
  const handleFileChange = async (event) => {
38
  const file = event.target.files[0];
39
  if (!file) return;
 
57
  reader.readAsText(file);
58
  };
59
 
60
+ // 4. Toggle between Admin and User modes.
61
  const handleAdminToggle = async () => {
62
  if (role === 'admin') {
63
  if (window.confirm("Switch back to User mode? Current data will be cleared.")) {
 
96
  <h1>FocusGuard</h1>
97
  <p>Your productivity monitor assistant.</p>
98
 
99
+ {/* Keep the hidden file input outside the button grid so it never affects layout. */}
100
  <input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileChange} />
101
 
102
+ {/* Render the four main actions inside a clean 2x2 grid. */}
103
  <div className="home-button-grid">
104
 
105
  <button className="btn-main" onClick={handleNewStart}>
 
123
  );
124
  }
125
 
126
+ export default Home;
src/components/Records.jsx CHANGED
@@ -4,16 +4,23 @@ function Records() {
4
  const [filter, setFilter] = useState('all');
5
  const [sessions, setSessions] = useState([]);
6
  const [loading, setLoading] = useState(false);
 
 
 
 
 
 
7
  const chartRef = useRef(null);
8
 
9
- //
10
  const formatDuration = (seconds) => {
11
- const mins = Math.floor(seconds / 60);
12
- const secs = seconds % 60;
 
13
  return `${mins}m ${secs}s`;
14
  };
15
 
16
- //
17
  const formatDate = (dateString) => {
18
  const date = new Date(dateString);
19
  return date.toLocaleDateString('en-US', {
@@ -24,7 +31,133 @@ function Records() {
24
  });
25
  };
26
 
27
- //
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  const loadSessions = async (filterType) => {
29
  setLoading(true);
30
  try {
@@ -39,7 +172,7 @@ function Records() {
39
  }
40
  };
41
 
42
- //
43
  const drawChart = (data) => {
44
  const canvas = chartRef.current;
45
  if (!canvas) return;
@@ -48,7 +181,7 @@ function Records() {
48
  const width = canvas.width = canvas.offsetWidth;
49
  const height = canvas.height = 300;
50
 
51
- //
52
  ctx.clearRect(0, 0, width, height);
53
 
54
  if (data.length === 0) {
@@ -59,17 +192,17 @@ function Records() {
59
  return;
60
  }
61
 
62
- //
63
  const displayData = data.slice(0, 20).reverse();
64
  const padding = 50;
65
  const chartWidth = width - padding * 2;
66
  const chartHeight = height - padding * 2;
67
  const barWidth = chartWidth / displayData.length;
68
 
69
- //
70
  const maxScore = 1.0;
71
 
72
- //
73
  ctx.strokeStyle = '#E0E0E0';
74
  ctx.lineWidth = 2;
75
  ctx.beginPath();
@@ -78,7 +211,7 @@ function Records() {
78
  ctx.lineTo(width - padding, height - padding);
79
  ctx.stroke();
80
 
81
- //
82
  ctx.fillStyle = '#666';
83
  ctx.font = '12px Nunito';
84
  ctx.textAlign = 'right';
@@ -87,7 +220,7 @@ function Records() {
87
  const value = (maxScore * i / 4 * 100).toFixed(0);
88
  ctx.fillText(value + '%', padding - 10, y + 4);
89
 
90
- //
91
  ctx.strokeStyle = '#F0F0F0';
92
  ctx.lineWidth = 1;
93
  ctx.beginPath();
@@ -96,14 +229,14 @@ function Records() {
96
  ctx.stroke();
97
  }
98
 
99
- //
100
  displayData.forEach((session, index) => {
101
  const barHeight = (session.focus_score / maxScore) * chartHeight;
102
  const x = padding + index * barWidth + barWidth * 0.1;
103
  const y = height - padding - barHeight;
104
  const barActualWidth = barWidth * 0.8;
105
 
106
- //
107
  const score = session.focus_score;
108
  let color;
109
  if (score >= 0.8) color = '#4A90E2';
@@ -114,35 +247,83 @@ function Records() {
114
  ctx.fillStyle = color;
115
  ctx.fillRect(x, y, barActualWidth, barHeight);
116
 
117
- //
118
  ctx.strokeStyle = color;
119
  ctx.lineWidth = 1;
120
  ctx.strokeRect(x, y, barActualWidth, barHeight);
121
  });
122
 
123
- //
124
  ctx.textAlign = 'left';
125
  ctx.font = 'bold 14px Nunito';
126
  ctx.fillStyle = '#4A90E2';
127
  ctx.fillText('Focus Score by Session', padding, 30);
128
  };
129
 
130
- //
131
  useEffect(() => {
132
  loadSessions(filter);
133
  }, [filter]);
134
 
135
- //
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  const handleFilterClick = (filterType) => {
137
  setFilter(filterType);
138
  };
139
 
140
- //
141
- const handleViewDetails = (sessionId) => {
142
- //
143
- alert(`View details for session ${sessionId}\n(Feature can be extended later)`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  };
145
 
 
 
146
  return (
147
  <main id="page-d" className="page">
148
  <h1 className="page-title">My Records</h1>
@@ -282,19 +463,7 @@ function Records() {
282
  <td style={{ padding: '15px', textAlign: 'center' }}>
283
  <button
284
  onClick={() => handleViewDetails(session.id)}
285
- style={{
286
- padding: '6px 20px',
287
- background: '#4A90E2',
288
- border: 'none',
289
- color: 'white',
290
- borderRadius: '6px',
291
- cursor: 'pointer',
292
- fontSize: '12px',
293
- fontWeight: '500',
294
- transition: 'background 0.3s'
295
- }}
296
- onMouseOver={(e) => e.target.style.background = '#357ABD'}
297
- onMouseOut={(e) => e.target.style.background = '#4A90E2'}
298
  >
299
  View
300
  </button>
@@ -305,6 +474,170 @@ function Records() {
305
  </table>
306
  )}
307
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  </main>
309
  );
310
  }
 
4
  const [filter, setFilter] = useState('all');
5
  const [sessions, setSessions] = useState([]);
6
  const [loading, setLoading] = useState(false);
7
+ const [detailState, setDetailState] = useState({
8
+ open: false,
9
+ loading: false,
10
+ error: '',
11
+ session: null
12
+ });
13
  const chartRef = useRef(null);
14
 
15
+ // Format a session duration.
16
  const formatDuration = (seconds) => {
17
+ const safeSeconds = Math.max(0, Number(seconds) || 0);
18
+ const mins = Math.floor(safeSeconds / 60);
19
+ const secs = safeSeconds % 60;
20
  return `${mins}m ${secs}s`;
21
  };
22
 
23
+ // Format a session timestamp for table display.
24
  const formatDate = (dateString) => {
25
  const date = new Date(dateString);
26
  return date.toLocaleDateString('en-US', {
 
31
  });
32
  };
33
 
34
+ const formatDateTime = (dateString) => {
35
+ if (!dateString) return 'Not available';
36
+ const date = new Date(dateString);
37
+ return date.toLocaleString('en-US', {
38
+ month: 'short',
39
+ day: 'numeric',
40
+ year: 'numeric',
41
+ hour: '2-digit',
42
+ minute: '2-digit'
43
+ });
44
+ };
45
+
46
+ const parseMetadata = (detectionData) => {
47
+ if (!detectionData) return {};
48
+ if (typeof detectionData === 'object') return detectionData;
49
+ try {
50
+ return JSON.parse(detectionData);
51
+ } catch (_) {
52
+ return {};
53
+ }
54
+ };
55
+
56
+ const averageOf = (values) => {
57
+ const valid = values.filter((value) => Number.isFinite(value));
58
+ if (valid.length === 0) return null;
59
+ return valid.reduce((sum, value) => sum + value, 0) / valid.length;
60
+ };
61
+
62
+ const buildTimelineSegments = (events, maxSegments = 48) => {
63
+ if (!events.length) return [];
64
+
65
+ const segmentSize = Math.ceil(events.length / maxSegments);
66
+ const segments = [];
67
+
68
+ for (let i = 0; i < events.length; i += segmentSize) {
69
+ const slice = events.slice(i, i + segmentSize);
70
+ const focusedCount = slice.filter((event) => event.isFocused).length;
71
+ const focusRatio = focusedCount / slice.length;
72
+ const confidence = averageOf(slice.map((event) => event.confidence));
73
+
74
+ let tone = 'distracted';
75
+ if (focusRatio >= 0.75) tone = 'focused';
76
+ else if (focusRatio >= 0.35) tone = 'mixed';
77
+
78
+ segments.push({
79
+ tone,
80
+ focusRatio,
81
+ confidence,
82
+ count: slice.length
83
+ });
84
+ }
85
+
86
+ return segments;
87
+ };
88
+
89
+ const buildDetailView = (session) => {
90
+ if (!session) return null;
91
+
92
+ const parsedEvents = (session.events || []).map((event) => {
93
+ const metadata = parseMetadata(event.detection_data);
94
+ return {
95
+ ...event,
96
+ metadata,
97
+ isFocused: Boolean(event.is_focused),
98
+ confidence: Number(event.confidence) || 0
99
+ };
100
+ });
101
+
102
+ const focusRatio = session.total_frames
103
+ ? session.focused_frames / session.total_frames
104
+ : parsedEvents.length
105
+ ? parsedEvents.filter((event) => event.isFocused).length / parsedEvents.length
106
+ : 0;
107
+
108
+ const modelCounts = parsedEvents.reduce((counts, event) => {
109
+ const model = event.metadata?.model;
110
+ if (model) counts[model] = (counts[model] || 0) + 1;
111
+ return counts;
112
+ }, {});
113
+
114
+ const dominantModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'Unavailable';
115
+ const avgConfidence = averageOf(parsedEvents.map((event) => event.confidence));
116
+ const avgFaceScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_face)));
117
+ const avgEyeScore = averageOf(parsedEvents.map((event) => Number(event.metadata?.s_eye)));
118
+ const avgMar = averageOf(parsedEvents.map((event) => Number(event.metadata?.mar)));
119
+
120
+ const startTime = session.start_time ? new Date(session.start_time) : null;
121
+ const timeline = buildTimelineSegments(parsedEvents);
122
+ const recentEvents = parsedEvents.slice(-10).reverse();
123
+
124
+ return {
125
+ parsedEvents,
126
+ focusRatio,
127
+ dominantModel,
128
+ avgConfidence,
129
+ avgFaceScore,
130
+ avgEyeScore,
131
+ avgMar,
132
+ timeline,
133
+ recentEvents,
134
+ formatOffset(timestamp) {
135
+ if (!startTime || !timestamp) return '--';
136
+ const offsetSeconds = Math.max(0, Math.round((new Date(timestamp) - startTime) / 1000));
137
+ const mins = Math.floor(offsetSeconds / 60);
138
+ const secs = offsetSeconds % 60;
139
+ return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
140
+ }
141
+ };
142
+ };
143
+
144
+ const getScoreTone = (score) => {
145
+ if (score >= 0.8) return 'excellent';
146
+ if (score >= 0.6) return 'good';
147
+ if (score >= 0.4) return 'fair';
148
+ return 'low';
149
+ };
150
+
151
+ const closeDetails = () => {
152
+ setDetailState({
153
+ open: false,
154
+ loading: false,
155
+ error: '',
156
+ session: null
157
+ });
158
+ };
159
+
160
+ // Load session rows for the selected filter.
161
  const loadSessions = async (filterType) => {
162
  setLoading(true);
163
  try {
 
172
  }
173
  };
174
 
175
+ // Draw the session score chart.
176
  const drawChart = (data) => {
177
  const canvas = chartRef.current;
178
  if (!canvas) return;
 
181
  const width = canvas.width = canvas.offsetWidth;
182
  const height = canvas.height = 300;
183
 
184
+ // Clear the canvas before each redraw.
185
  ctx.clearRect(0, 0, width, height);
186
 
187
  if (data.length === 0) {
 
192
  return;
193
  }
194
 
195
+ // Use at most the latest 20 sessions in the chart.
196
  const displayData = data.slice(0, 20).reverse();
197
  const padding = 50;
198
  const chartWidth = width - padding * 2;
199
  const chartHeight = height - padding * 2;
200
  const barWidth = chartWidth / displayData.length;
201
 
202
+ // Use a normalized max score for chart scaling.
203
  const maxScore = 1.0;
204
 
205
+ // Draw the chart axes.
206
  ctx.strokeStyle = '#E0E0E0';
207
  ctx.lineWidth = 2;
208
  ctx.beginPath();
 
211
  ctx.lineTo(width - padding, height - padding);
212
  ctx.stroke();
213
 
214
+ // Draw Y-axis labels.
215
  ctx.fillStyle = '#666';
216
  ctx.font = '12px Nunito';
217
  ctx.textAlign = 'right';
 
220
  const value = (maxScore * i / 4 * 100).toFixed(0);
221
  ctx.fillText(value + '%', padding - 10, y + 4);
222
 
223
+ // Draw horizontal grid lines.
224
  ctx.strokeStyle = '#F0F0F0';
225
  ctx.lineWidth = 1;
226
  ctx.beginPath();
 
229
  ctx.stroke();
230
  }
231
 
232
+ // Draw the bar chart.
233
  displayData.forEach((session, index) => {
234
  const barHeight = (session.focus_score / maxScore) * chartHeight;
235
  const x = padding + index * barWidth + barWidth * 0.1;
236
  const y = height - padding - barHeight;
237
  const barActualWidth = barWidth * 0.8;
238
 
239
+ // Map each score to a blue-toned color band.
240
  const score = session.focus_score;
241
  let color;
242
  if (score >= 0.8) color = '#4A90E2';
 
247
  ctx.fillStyle = color;
248
  ctx.fillRect(x, y, barActualWidth, barHeight);
249
 
250
+ // Draw a matching outline around each bar.
251
  ctx.strokeStyle = color;
252
  ctx.lineWidth = 1;
253
  ctx.strokeRect(x, y, barActualWidth, barHeight);
254
  });
255
 
256
+ // Draw the chart title.
257
  ctx.textAlign = 'left';
258
  ctx.font = 'bold 14px Nunito';
259
  ctx.fillStyle = '#4A90E2';
260
  ctx.fillText('Focus Score by Session', padding, 30);
261
  };
262
 
263
+ // Initial load.
264
  useEffect(() => {
265
  loadSessions(filter);
266
  }, [filter]);
267
 
268
+ useEffect(() => {
269
+ if (!detailState.open) return undefined;
270
+
271
+ const previousOverflow = document.body.style.overflow;
272
+ document.body.style.overflow = 'hidden';
273
+
274
+ const handleKeyDown = (event) => {
275
+ if (event.key === 'Escape') {
276
+ closeDetails();
277
+ }
278
+ };
279
+
280
+ window.addEventListener('keydown', handleKeyDown);
281
+
282
+ return () => {
283
+ document.body.style.overflow = previousOverflow;
284
+ window.removeEventListener('keydown', handleKeyDown);
285
+ };
286
+ }, [detailState.open]);
287
+
288
+ // Filter button handler.
289
  const handleFilterClick = (filterType) => {
290
  setFilter(filterType);
291
  };
292
 
293
+ // Open the detail modal for one session.
294
+ const handleViewDetails = async (sessionId) => {
295
+ setDetailState({
296
+ open: true,
297
+ loading: true,
298
+ error: '',
299
+ session: null
300
+ });
301
+
302
+ try {
303
+ const response = await fetch(`/api/sessions/${sessionId}`);
304
+ if (!response.ok) {
305
+ throw new Error('Failed to load session details.');
306
+ }
307
+
308
+ const data = await response.json();
309
+ setDetailState({
310
+ open: true,
311
+ loading: false,
312
+ error: '',
313
+ session: data
314
+ });
315
+ } catch (error) {
316
+ setDetailState({
317
+ open: true,
318
+ loading: false,
319
+ error: error.message || 'Failed to load session details.',
320
+ session: null
321
+ });
322
+ }
323
  };
324
 
325
+ const detailView = buildDetailView(detailState.session);
326
+
327
  return (
328
  <main id="page-d" className="page">
329
  <h1 className="page-title">My Records</h1>
 
463
  <td style={{ padding: '15px', textAlign: 'center' }}>
464
  <button
465
  onClick={() => handleViewDetails(session.id)}
466
+ className="btn-view"
 
 
 
 
 
 
 
 
 
 
 
 
467
  >
468
  View
469
  </button>
 
474
  </table>
475
  )}
476
  </div>
477
+
478
+ {detailState.open ? (
479
+ <div className="modal-overlay" onClick={closeDetails}>
480
+ <div className="modal-content records-detail-modal" onClick={(event) => event.stopPropagation()}>
481
+ <div className="records-detail-header">
482
+ <div>
483
+ <div className="records-detail-kicker">Session Detail</div>
484
+ <h2>
485
+ {detailState.session ? formatDateTime(detailState.session.start_time) : 'Loading session'}
486
+ </h2>
487
+ <p className="records-detail-subtitle">
488
+ Review score, capture quality, and a condensed event timeline for this session.
489
+ </p>
490
+ </div>
491
+ <button type="button" className="records-detail-close" onClick={closeDetails}>
492
+ Close
493
+ </button>
494
+ </div>
495
+
496
+ {detailState.loading ? (
497
+ <div className="records-detail-feedback">Loading session details...</div>
498
+ ) : detailState.error ? (
499
+ <div className="records-detail-feedback records-detail-feedback-error">{detailState.error}</div>
500
+ ) : detailState.session && detailView ? (
501
+ <>
502
+ <section className="records-detail-summary">
503
+ <article className={`records-detail-stat ${getScoreTone(detailState.session.focus_score)}`}>
504
+ <span className="records-detail-stat-label">Focus Score</span>
505
+ <strong className="records-detail-stat-value">
506
+ {(detailState.session.focus_score * 100).toFixed(1)}%
507
+ </strong>
508
+ </article>
509
+ <article className="records-detail-stat">
510
+ <span className="records-detail-stat-label">Duration</span>
511
+ <strong className="records-detail-stat-value">
512
+ {formatDuration(detailState.session.duration_seconds)}
513
+ </strong>
514
+ </article>
515
+ <article className="records-detail-stat">
516
+ <span className="records-detail-stat-label">Frames Analysed</span>
517
+ <strong className="records-detail-stat-value">{detailState.session.total_frames}</strong>
518
+ </article>
519
+ <article className="records-detail-stat">
520
+ <span className="records-detail-stat-label">Focused Frames</span>
521
+ <strong className="records-detail-stat-value">
522
+ {(detailView.focusRatio * 100).toFixed(1)}%
523
+ </strong>
524
+ </article>
525
+ </section>
526
+
527
+ <section className="records-detail-grid">
528
+ <article className="records-detail-card">
529
+ <h3>Session Info</h3>
530
+ <div className="records-detail-list">
531
+ <div className="records-detail-item">
532
+ <span className="records-detail-item-label">Started</span>
533
+ <span className="records-detail-item-value">{formatDateTime(detailState.session.start_time)}</span>
534
+ </div>
535
+ <div className="records-detail-item">
536
+ <span className="records-detail-item-label">Ended</span>
537
+ <span className="records-detail-item-value">{formatDateTime(detailState.session.end_time)}</span>
538
+ </div>
539
+ <div className="records-detail-item">
540
+ <span className="records-detail-item-label">Dominant Model</span>
541
+ <span className="records-detail-item-value">{detailView.dominantModel}</span>
542
+ </div>
543
+ <div className="records-detail-item">
544
+ <span className="records-detail-item-label">Event Samples</span>
545
+ <span className="records-detail-item-value">{detailView.parsedEvents.length}</span>
546
+ </div>
547
+ </div>
548
+ </article>
549
+
550
+ <article className="records-detail-card">
551
+ <h3>Signal Quality</h3>
552
+ <div className="records-detail-list">
553
+ <div className="records-detail-item">
554
+ <span className="records-detail-item-label">Avg Confidence</span>
555
+ <span className="records-detail-item-value">
556
+ {detailView.avgConfidence !== null ? `${(detailView.avgConfidence * 100).toFixed(1)}%` : '--'}
557
+ </span>
558
+ </div>
559
+ <div className="records-detail-item">
560
+ <span className="records-detail-item-label">Avg Face Score</span>
561
+ <span className="records-detail-item-value">
562
+ {detailView.avgFaceScore !== null ? detailView.avgFaceScore.toFixed(3) : '--'}
563
+ </span>
564
+ </div>
565
+ <div className="records-detail-item">
566
+ <span className="records-detail-item-label">Avg Eye Score</span>
567
+ <span className="records-detail-item-value">
568
+ {detailView.avgEyeScore !== null ? detailView.avgEyeScore.toFixed(3) : '--'}
569
+ </span>
570
+ </div>
571
+ <div className="records-detail-item">
572
+ <span className="records-detail-item-label">Avg MAR</span>
573
+ <span className="records-detail-item-value">
574
+ {detailView.avgMar !== null ? detailView.avgMar.toFixed(3) : '--'}
575
+ </span>
576
+ </div>
577
+ </div>
578
+ </article>
579
+ </section>
580
+
581
+ <section className="records-detail-card">
582
+ <div className="records-detail-section-head">
583
+ <h3>Focus Timeline</h3>
584
+ <span>{detailView.parsedEvents.length} events condensed</span>
585
+ </div>
586
+ {detailView.timeline.length > 0 ? (
587
+ <>
588
+ <div className="records-detail-timeline">
589
+ {detailView.timeline.map((segment, index) => (
590
+ <div
591
+ key={`${segment.tone}-${index}`}
592
+ className={`records-detail-segment ${segment.tone}`}
593
+ title={`${(segment.focusRatio * 100).toFixed(0)}% focused, ${segment.count} events`}
594
+ />
595
+ ))}
596
+ </div>
597
+ <div className="records-detail-legend">
598
+ <span><i className="records-detail-dot focused" />Focused</span>
599
+ <span><i className="records-detail-dot mixed" />Mixed</span>
600
+ <span><i className="records-detail-dot distracted" />Distracted</span>
601
+ </div>
602
+ </>
603
+ ) : (
604
+ <div className="records-detail-empty">No event timeline was recorded for this session.</div>
605
+ )}
606
+ </section>
607
+
608
+ <section className="records-detail-card">
609
+ <div className="records-detail-section-head">
610
+ <h3>Recent Events</h3>
611
+ <span>Last {detailView.recentEvents.length} samples</span>
612
+ </div>
613
+ {detailView.recentEvents.length > 0 ? (
614
+ <div className="records-detail-events">
615
+ {detailView.recentEvents.map((event) => (
616
+ <article key={event.id} className="records-detail-event">
617
+ <div className="records-detail-event-time">{detailView.formatOffset(event.timestamp)}</div>
618
+ <div className="records-detail-event-copy">
619
+ <div className="records-detail-event-status">
620
+ {event.isFocused ? 'Focused' : 'Distracted'}
621
+ </div>
622
+ <div className="records-detail-event-meta">
623
+ {event.metadata?.model || 'model n/a'} · confidence {(event.confidence * 100).toFixed(1)}%
624
+ </div>
625
+ </div>
626
+ <div className={`records-detail-event-badge ${event.isFocused ? 'focused' : 'distracted'}`}>
627
+ {event.isFocused ? 'OK' : 'Alert'}
628
+ </div>
629
+ </article>
630
+ ))}
631
+ </div>
632
+ ) : (
633
+ <div className="records-detail-empty">No individual event samples are available.</div>
634
+ )}
635
+ </section>
636
+ </>
637
+ ) : null}
638
+ </div>
639
+ </div>
640
+ ) : null}
641
  </main>
642
  );
643
  }
src/utils/VideoManager.js DELETED
@@ -1,347 +0,0 @@
1
- // src/utils/VideoManager.js
2
-
3
- export class VideoManager {
4
- constructor(callbacks) {
5
- this.callbacks = callbacks || {};
6
-
7
- this.videoElement = null;
8
- this.stream = null;
9
- this.pc = null;
10
- this.dataChannel = null;
11
-
12
- this.isStreaming = false;
13
- this.sessionId = null;
14
- this.frameRate = 30;
15
-
16
- this.currentStatus = false;
17
- this.statusBuffer = [];
18
- this.bufferSize = 5;
19
-
20
- this.latestDetectionData = null;
21
- this.lastConfidence = 0;
22
- this.detectionHoldMs = 30;
23
-
24
- this.notificationEnabled = true;
25
- this.notificationThreshold = 30;
26
- this.unfocusedStartTime = null;
27
- this.lastNotificationTime = null;
28
- this.notificationCooldown = 60000;
29
- }
30
-
31
- async initCamera(videoRef) {
32
- try {
33
- this.stream = await navigator.mediaDevices.getUserMedia({
34
- video: {
35
- width: { ideal: 640 },
36
- height: { ideal: 480 },
37
- facingMode: 'user'
38
- },
39
- audio: false
40
- });
41
-
42
- this.videoElement = videoRef;
43
- return true;
44
- } catch (error) {
45
- console.error('Camera init error:', error);
46
- throw error;
47
- }
48
- }
49
-
50
- async startStreaming() {
51
- if (!this.stream) {
52
- console.error('❌ No stream available');
53
- throw new Error('Camera stream not initialized');
54
- }
55
- this.isStreaming = true;
56
-
57
- console.log('📹 Starting streaming...');
58
-
59
- //
60
- await this.requestNotificationPermission();
61
- //
62
- await this.loadNotificationSettings();
63
-
64
- this.pc = new RTCPeerConnection({
65
- iceServers: [
66
- { urls: 'stun:stun.l.google.com:19302' },
67
- { urls: 'stun:stun1.l.google.com:19302' },
68
- { urls: 'stun:stun2.l.google.com:19302' },
69
- { urls: 'stun:stun3.l.google.com:19302' },
70
- { urls: 'stun:stun4.l.google.com:19302' }
71
- ],
72
- iceCandidatePoolSize: 10
73
- });
74
-
75
- //
76
- this.pc.onconnectionstatechange = () => {
77
- console.log('🔗 Connection state:', this.pc.connectionState);
78
- };
79
-
80
- this.pc.oniceconnectionstatechange = () => {
81
- console.log('🧊 ICE connection state:', this.pc.iceConnectionState);
82
- };
83
-
84
- this.pc.onicegatheringstatechange = () => {
85
- console.log('📡 ICE gathering state:', this.pc.iceGatheringState);
86
- };
87
-
88
- // DataChannel for status updates
89
- this.dataChannel = this.pc.createDataChannel('status');
90
- this.dataChannel.onmessage = (event) => {
91
- try {
92
- const data = JSON.parse(event.data);
93
- this.handleServerMessage(data);
94
- } catch (e) {
95
- console.error('Failed to parse data channel message:', e);
96
- }
97
- };
98
-
99
- this.pc.ontrack = (event) => {
100
- const stream = event.streams[0];
101
- if (this.videoElement) {
102
- this.videoElement.srcObject = stream;
103
- this.videoElement.autoplay = true;
104
- this.videoElement.playsInline = true;
105
- this.videoElement.play().catch(() => {});
106
- }
107
- };
108
-
109
- // Add local camera tracks
110
- this.stream.getTracks().forEach((track) => {
111
- this.pc.addTrack(track, this.stream);
112
- });
113
-
114
- const offer = await this.pc.createOffer();
115
- await this.pc.setLocalDescription(offer);
116
-
117
- // Wait for ICE gathering to complete so SDP includes candidates
118
- await new Promise((resolve) => {
119
- if (this.pc.iceGatheringState === 'complete') {
120
- resolve();
121
- return;
122
- }
123
- const onIce = () => {
124
- if (this.pc.iceGatheringState === 'complete') {
125
- this.pc.removeEventListener('icegatheringstatechange', onIce);
126
- resolve();
127
- }
128
- };
129
- this.pc.addEventListener('icegatheringstatechange', onIce);
130
- });
131
-
132
- console.log('📤 Sending offer to server...');
133
- const response = await fetch('/api/webrtc/offer', {
134
- method: 'POST',
135
- headers: { 'Content-Type': 'application/json' },
136
- body: JSON.stringify({
137
- sdp: this.pc.localDescription.sdp,
138
- type: this.pc.localDescription.type
139
- })
140
- });
141
-
142
- if (!response.ok) {
143
- const errorText = await response.text();
144
- console.error('❌ Server error:', errorText);
145
- throw new Error(`Server returned ${response.status}: ${errorText}`);
146
- }
147
-
148
- const answer = await response.json();
149
- console.log('✅ Received answer from server, session_id:', answer.session_id);
150
-
151
- await this.pc.setRemoteDescription(answer);
152
- console.log('��� Remote description set successfully');
153
-
154
- this.sessionId = answer.session_id;
155
- if (this.callbacks.onSessionStart) {
156
- this.callbacks.onSessionStart(this.sessionId);
157
- }
158
-
159
- }
160
-
161
- async requestNotificationPermission() {
162
- if ('Notification' in window && Notification.permission === 'default') {
163
- try {
164
- await Notification.requestPermission();
165
- } catch (error) {
166
- console.error('Failed to request notification permission:', error);
167
- }
168
- }
169
- }
170
-
171
- async loadNotificationSettings() {
172
- try {
173
- const response = await fetch('/api/settings');
174
- const settings = await response.json();
175
- if (settings) {
176
- this.notificationEnabled = settings.notification_enabled ?? true;
177
- this.notificationThreshold = settings.notification_threshold ?? 30;
178
- }
179
- } catch (error) {
180
- console.error('Failed to load notification settings:', error);
181
- }
182
- }
183
-
184
- sendNotification(title, message) {
185
- if (!this.notificationEnabled) return;
186
- if ('Notification' in window && Notification.permission === 'granted') {
187
- try {
188
- const notification = new Notification(title, {
189
- body: message,
190
- icon: '/vite.svg',
191
- badge: '/vite.svg',
192
- tag: 'focus-guard-distraction',
193
- requireInteraction: false
194
- });
195
-
196
- //
197
- setTimeout(() => notification.close(), 3000);
198
- } catch (error) {
199
- console.error('Failed to send notification:', error);
200
- }
201
- }
202
- }
203
-
204
- handleServerMessage(data) {
205
- switch (data.type) {
206
- case 'detection':
207
- this.updateStatus(data.focused);
208
- this.latestDetectionData = {
209
- detections: data.detections || [],
210
- confidence: data.confidence || 0,
211
- focused: data.focused,
212
- timestamp: performance.now()
213
- };
214
- this.lastConfidence = data.confidence || 0;
215
-
216
- if (this.callbacks.onStatusUpdate) {
217
- this.callbacks.onStatusUpdate(this.currentStatus);
218
- }
219
- break;
220
- default:
221
- break;
222
- }
223
- }
224
-
225
- updateStatus(newFocused) {
226
- this.statusBuffer.push(newFocused);
227
- if (this.statusBuffer.length > this.bufferSize) {
228
- this.statusBuffer.shift();
229
- }
230
-
231
- if (this.statusBuffer.length < this.bufferSize) return false;
232
-
233
- const focusedCount = this.statusBuffer.filter(f => f).length;
234
- const focusedRatio = focusedCount / this.statusBuffer.length;
235
-
236
- const previousStatus = this.currentStatus;
237
-
238
- if (focusedRatio >= 0.75) {
239
- this.currentStatus = true;
240
- } else if (focusedRatio <= 0.25) {
241
- this.currentStatus = false;
242
- }
243
-
244
- //
245
- this.handleNotificationLogic(previousStatus, this.currentStatus);
246
- }
247
-
248
- handleNotificationLogic(previousStatus, currentStatus) {
249
- const now = Date.now();
250
-
251
- //
252
- if (previousStatus && !currentStatus) {
253
- this.unfocusedStartTime = now;
254
- }
255
-
256
- //
257
- if (!previousStatus && currentStatus) {
258
- this.unfocusedStartTime = null;
259
- }
260
-
261
- //
262
- if (!currentStatus && this.unfocusedStartTime) {
263
- const unfocusedDuration = (now - this.unfocusedStartTime) / 1000;
264
-
265
- //
266
- if (unfocusedDuration >= this.notificationThreshold) {
267
- const canSendNotification = !this.lastNotificationTime ||
268
- (now - this.lastNotificationTime) >= this.notificationCooldown;
269
-
270
- if (canSendNotification) {
271
- this.sendNotification(
272
- '⚠️ Focus Alert',
273
- `You've been distracted for ${Math.floor(unfocusedDuration)} seconds. Get back to work!`
274
- );
275
- this.lastNotificationTime = now;
276
- }
277
- }
278
- }
279
- }
280
-
281
- async stopStreaming() {
282
- this.isStreaming = false;
283
-
284
- try {
285
- if (document.pictureInPictureElement) {
286
- await document.exitPictureInPicture();
287
- }
288
- if (this.videoElement && typeof this.videoElement.webkitSetPresentationMode === 'function') {
289
- if (this.videoElement.webkitPresentationMode === 'picture-in-picture') {
290
- this.videoElement.webkitSetPresentationMode('inline');
291
- }
292
- }
293
- } catch (e) {
294
- // ignore PiP exit errors
295
- }
296
-
297
- if (this.pc) {
298
- try {
299
- this.pc.getSenders().forEach(sender => sender.track && sender.track.stop());
300
- this.pc.close();
301
- } catch (e) {
302
- console.error('Failed to close RTCPeerConnection:', e);
303
- }
304
- this.pc = null;
305
- }
306
-
307
- if (this.stream) {
308
- this.stream.getTracks().forEach(track => track.stop());
309
- this.stream = null;
310
- }
311
-
312
- if (this.videoElement) {
313
- this.videoElement.srcObject = null;
314
- }
315
-
316
- if (this.sessionId) {
317
- try {
318
- const response = await fetch('/api/sessions/end', {
319
- method: 'POST',
320
- headers: { 'Content-Type': 'application/json' },
321
- body: JSON.stringify({ session_id: this.sessionId })
322
- });
323
- const summary = await response.json();
324
- if (this.callbacks.onSessionEnd) {
325
- this.callbacks.onSessionEnd(summary);
326
- }
327
- } catch (e) {
328
- console.error('Failed to end session:', e);
329
- }
330
- }
331
-
332
- //
333
- this.unfocusedStartTime = null;
334
- this.lastNotificationTime = null;
335
- this.sessionId = null;
336
- }
337
-
338
- setFrameRate(rate) {
339
- this.frameRate = Math.max(1, Math.min(60, rate));
340
- if (this.stream) {
341
- const videoTrack = this.stream.getVideoTracks()[0];
342
- if (videoTrack && videoTrack.applyConstraints) {
343
- videoTrack.applyConstraints({ frameRate: { ideal: this.frameRate, max: this.frameRate } }).catch(() => {});
344
- }
345
- }
346
- }
347
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/utils/VideoManagerLocal.js CHANGED
@@ -1,12 +1,12 @@
1
  // src/utils/VideoManagerLocal.js
2
- // WebSocket + Canvas (no WebRTC)
3
 
4
  export class VideoManagerLocal {
5
  constructor(callbacks) {
6
  this.callbacks = callbacks || {};
7
 
8
- this.localVideoElement = null;
9
- this.displayVideoElement = null;
10
  this.canvas = null;
11
  this.stream = null;
12
  this.ws = null;
@@ -14,13 +14,16 @@ export class VideoManagerLocal {
14
  this.isStreaming = false;
15
  this.sessionId = null;
16
  this.sessionStartTime = null;
17
- this.frameRate = 15;
18
  this.captureInterval = null;
 
19
 
 
20
  this.currentStatus = false;
21
  this.statusBuffer = [];
22
  this.bufferSize = 3;
23
 
 
24
  this.latestDetectionData = null;
25
  this.lastConfidence = 0;
26
 
@@ -30,12 +33,14 @@ export class VideoManagerLocal {
30
  // Continuous render loop
31
  this._animFrameId = null;
32
 
 
33
  this.notificationEnabled = true;
34
  this.notificationThreshold = 30;
35
  this.unfocusedStartTime = null;
36
  this.lastNotificationTime = null;
37
  this.notificationCooldown = 60000;
38
 
 
39
  this.stats = {
40
  framesSent: 0,
41
  framesProcessed: 0,
@@ -44,6 +49,7 @@ export class VideoManagerLocal {
44
  };
45
  }
46
 
 
47
  async initCamera(localVideoRef, displayCanvasRef) {
48
  try {
49
  console.log('Initializing local camera...');
@@ -60,11 +66,13 @@ export class VideoManagerLocal {
60
  this.localVideoElement = localVideoRef;
61
  this.displayCanvas = displayCanvasRef;
62
 
 
63
  if (this.localVideoElement) {
64
  this.localVideoElement.srcObject = this.stream;
65
  this.localVideoElement.play();
66
  }
67
 
 
68
  this.canvas = document.createElement('canvas');
69
  this.canvas.width = 320;
70
  this.canvas.height = 240;
@@ -77,6 +85,7 @@ export class VideoManagerLocal {
77
  }
78
  }
79
 
 
80
  async startStreaming() {
81
  if (!this.stream) {
82
  throw new Error('Camera not initialized');
@@ -90,35 +99,64 @@ export class VideoManagerLocal {
90
  console.log('Starting WebSocket streaming...');
91
  this.isStreaming = true;
92
 
93
- // Fetch tessellation topology (once)
94
- if (!this._tessellation) {
95
- try {
96
- const res = await fetch('/api/mesh-topology');
97
- const data = await res.json();
98
- this._tessellation = data.tessellation; // [[start, end], ...]
99
- } catch (e) {
100
- console.warn('Failed to fetch mesh topology:', e);
 
 
101
  }
102
- }
103
 
104
- //
105
- await this.requestNotificationPermission();
106
- await this.loadNotificationSettings();
 
 
 
107
 
108
- //
109
- await this.connectWebSocket();
110
 
111
- //
112
- this.startCapture();
 
113
 
114
- // Start continuous render loop for smooth video
115
- this._lastDetection = null;
116
- this._startRenderLoop();
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- console.log('Streaming started');
 
 
 
 
 
 
 
 
 
 
 
 
119
  }
120
 
121
- //
122
  async connectWebSocket() {
123
  return new Promise((resolve, reject) => {
124
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -126,17 +164,28 @@ export class VideoManagerLocal {
126
 
127
  console.log('Connecting to WebSocket:', wsUrl);
128
 
129
- this.ws = new WebSocket(wsUrl);
 
130
 
131
- this.ws.onopen = () => {
 
 
 
 
 
 
 
 
 
 
132
  console.log('WebSocket connected');
133
 
134
- //
135
- this.ws.send(JSON.stringify({ type: 'start_session' }));
136
  resolve();
137
  };
138
 
139
- this.ws.onmessage = (event) => {
140
  try {
141
  const data = JSON.parse(event.data);
142
  this.handleServerMessage(data);
@@ -145,22 +194,40 @@ export class VideoManagerLocal {
145
  }
146
  };
147
 
148
- this.ws.onerror = (error) => {
149
- console.error('WebSocket error:', error);
150
- reject(error);
151
  };
152
 
153
- this.ws.onclose = () => {
154
- console.log('WebSocket disconnected');
 
 
 
 
 
 
 
 
 
155
  if (this.isStreaming) {
156
  console.log('Attempting to reconnect...');
157
- setTimeout(() => this.connectWebSocket(), 2000);
 
 
 
 
 
 
 
 
 
158
  }
159
  };
160
  });
161
  }
162
 
163
- //
164
  startCapture() {
165
  const interval = 1000 / this.frameRate;
166
  this._sendingBlob = false; // prevent overlapping toBlob calls
@@ -264,7 +331,7 @@ export class VideoManagerLocal {
264
  }
265
  }
266
 
267
- //
268
  handleServerMessage(data) {
269
  switch (data.type) {
270
  case 'session_started':
@@ -578,21 +645,26 @@ export class VideoManagerLocal {
578
 
579
  this.isStreaming = false;
580
 
581
- // Stop render loop
 
 
 
 
 
582
  this._stopRenderLoop();
583
  this._lastDetection = null;
584
 
585
- //
586
  if (this.captureInterval) {
587
  clearInterval(this.captureInterval);
588
  this.captureInterval = null;
589
  }
590
 
591
- //
592
  if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sessionId) {
593
  const sessionId = this.sessionId;
594
 
595
- //
596
  const waitForSessionEnd = new Promise((resolve) => {
597
  const originalHandler = this.ws.onmessage;
598
  const timeout = setTimeout(() => {
@@ -610,7 +682,7 @@ export class VideoManagerLocal {
610
  this.ws.onmessage = originalHandler;
611
  resolve();
612
  } else {
613
- //
614
  this.handleServerMessage(data);
615
  }
616
  } catch (e) {
@@ -625,37 +697,37 @@ export class VideoManagerLocal {
625
  session_id: sessionId
626
  }));
627
 
628
- //
629
  await waitForSessionEnd;
630
  }
631
 
632
- //
633
  await new Promise(resolve => setTimeout(resolve, 200));
634
 
635
- //
636
  if (this.ws) {
637
  this.ws.close();
638
  this.ws = null;
639
  }
640
 
641
- //
642
  if (this.stream) {
643
  this.stream.getTracks().forEach(track => track.stop());
644
  this.stream = null;
645
  }
646
 
647
- //
648
  if (this.localVideoElement) {
649
  this.localVideoElement.srcObject = null;
650
  }
651
 
652
- //
653
  if (this.displayCanvas) {
654
  const ctx = this.displayCanvas.getContext('2d');
655
  ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
656
  }
657
 
658
- //
659
  this.unfocusedStartTime = null;
660
  this.lastNotificationTime = null;
661
 
@@ -667,7 +739,7 @@ export class VideoManagerLocal {
667
  this.frameRate = Math.max(10, Math.min(30, rate));
668
  console.log(`Frame rate set to ${this.frameRate} FPS`);
669
 
670
- //
671
  if (this.isStreaming && this.captureInterval) {
672
  clearInterval(this.captureInterval);
673
  this.startCapture();
 
1
  // src/utils/VideoManagerLocal.js
2
+ // Local video processing implementation using WebSocket + Canvas, without WebRTC.
3
 
4
  export class VideoManagerLocal {
5
  constructor(callbacks) {
6
  this.callbacks = callbacks || {};
7
 
8
+ this.localVideoElement = null; // Local camera preview element.
9
+ this.displayVideoElement = null; // Processed output display element.
10
  this.canvas = null;
11
  this.stream = null;
12
  this.ws = null;
 
14
  this.isStreaming = false;
15
  this.sessionId = null;
16
  this.sessionStartTime = null;
17
+ this.frameRate = 15; // Lower FPS reduces transfer and processing load.
18
  this.captureInterval = null;
19
+ this.reconnectTimeout = null;
20
 
21
+ // Status smoothing
22
  this.currentStatus = false;
23
  this.statusBuffer = [];
24
  this.bufferSize = 3;
25
 
26
+ // Detection data
27
  this.latestDetectionData = null;
28
  this.lastConfidence = 0;
29
 
 
33
  // Continuous render loop
34
  this._animFrameId = null;
35
 
36
+ // Notification state
37
  this.notificationEnabled = true;
38
  this.notificationThreshold = 30;
39
  this.unfocusedStartTime = null;
40
  this.lastNotificationTime = null;
41
  this.notificationCooldown = 60000;
42
 
43
+ // Performance metrics
44
  this.stats = {
45
  framesSent: 0,
46
  framesProcessed: 0,
 
49
  };
50
  }
51
 
52
+ // Initialize the camera
53
  async initCamera(localVideoRef, displayCanvasRef) {
54
  try {
55
  console.log('Initializing local camera...');
 
66
  this.localVideoElement = localVideoRef;
67
  this.displayCanvas = displayCanvasRef;
68
 
69
+ // Show the local camera stream
70
  if (this.localVideoElement) {
71
  this.localVideoElement.srcObject = this.stream;
72
  this.localVideoElement.play();
73
  }
74
 
75
+ // Create a smaller capture canvas for faster encoding and transfer.
76
  this.canvas = document.createElement('canvas');
77
  this.canvas.width = 320;
78
  this.canvas.height = 240;
 
85
  }
86
  }
87
 
88
+ // Start streaming
89
  async startStreaming() {
90
  if (!this.stream) {
91
  throw new Error('Camera not initialized');
 
99
  console.log('Starting WebSocket streaming...');
100
  this.isStreaming = true;
101
 
102
+ try {
103
+ // Fetch tessellation topology (once)
104
+ if (!this._tessellation) {
105
+ try {
106
+ const res = await fetch('/api/mesh-topology');
107
+ const data = await res.json();
108
+ this._tessellation = data.tessellation; // [[start, end], ...]
109
+ } catch (e) {
110
+ console.warn('Failed to fetch mesh topology:', e);
111
+ }
112
  }
 
113
 
114
+ // Request notification permission
115
+ await this.requestNotificationPermission();
116
+ await this.loadNotificationSettings();
117
+
118
+ // Open the WebSocket connection
119
+ await this.connectWebSocket();
120
 
121
+ // Start sending captured frames on a timer
122
+ this.startCapture();
123
 
124
+ // Start continuous render loop for smooth video
125
+ this._lastDetection = null;
126
+ this._startRenderLoop();
127
 
128
+ console.log('Streaming started');
129
+ } catch (error) {
130
+ this.isStreaming = false;
131
+ this._stopRenderLoop();
132
+ this._lastDetection = null;
133
+
134
+ if (this.captureInterval) {
135
+ clearInterval(this.captureInterval);
136
+ this.captureInterval = null;
137
+ }
138
+
139
+ if (this.reconnectTimeout) {
140
+ clearTimeout(this.reconnectTimeout);
141
+ this.reconnectTimeout = null;
142
+ }
143
 
144
+ if (this.ws) {
145
+ this.ws.onopen = null;
146
+ this.ws.onmessage = null;
147
+ this.ws.onerror = null;
148
+ this.ws.onclose = null;
149
+ try {
150
+ this.ws.close();
151
+ } catch (_) {}
152
+ this.ws = null;
153
+ }
154
+
155
+ throw error instanceof Error ? error : new Error('Failed to start video streaming.');
156
+ }
157
  }
158
 
159
+ // Connect the WebSocket
160
  async connectWebSocket() {
161
  return new Promise((resolve, reject) => {
162
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
 
164
 
165
  console.log('Connecting to WebSocket:', wsUrl);
166
 
167
+ const socket = new WebSocket(wsUrl);
168
+ this.ws = socket;
169
 
170
+ let settled = false;
171
+ let opened = false;
172
+ const rejectWithMessage = (message) => {
173
+ if (settled) return;
174
+ settled = true;
175
+ reject(new Error(message));
176
+ };
177
+
178
+ socket.onopen = () => {
179
+ opened = true;
180
+ settled = true;
181
  console.log('WebSocket connected');
182
 
183
+ // Send the start-session control message
184
+ socket.send(JSON.stringify({ type: 'start_session' }));
185
  resolve();
186
  };
187
 
188
+ socket.onmessage = (event) => {
189
  try {
190
  const data = JSON.parse(event.data);
191
  this.handleServerMessage(data);
 
194
  }
195
  };
196
 
197
+ socket.onerror = () => {
198
+ console.error('WebSocket error:', { url: wsUrl, readyState: socket.readyState });
199
+ rejectWithMessage(`Failed to connect to ${wsUrl}. Check that the backend server is running and reachable.`);
200
  };
201
 
202
+ socket.onclose = (event) => {
203
+ console.log('WebSocket disconnected', event.code, event.reason);
204
+ if (this.ws === socket) {
205
+ this.ws = null;
206
+ }
207
+
208
+ if (!opened) {
209
+ rejectWithMessage(`WebSocket closed before connection was established (${event.code || 'no code'}). Check that the backend server is running on the expected port.`);
210
+ return;
211
+ }
212
+
213
  if (this.isStreaming) {
214
  console.log('Attempting to reconnect...');
215
+ if (this.reconnectTimeout) {
216
+ clearTimeout(this.reconnectTimeout);
217
+ }
218
+ this.reconnectTimeout = setTimeout(() => {
219
+ this.reconnectTimeout = null;
220
+ if (!this.isStreaming) return;
221
+ this.connectWebSocket().catch((error) => {
222
+ console.error('Reconnect failed:', error);
223
+ });
224
+ }, 2000);
225
  }
226
  };
227
  });
228
  }
229
 
230
+ // Capture and send frames (binary blobs for speed)
231
  startCapture() {
232
  const interval = 1000 / this.frameRate;
233
  this._sendingBlob = false; // prevent overlapping toBlob calls
 
331
  }
332
  }
333
 
334
+ // Handle messages from the server
335
  handleServerMessage(data) {
336
  switch (data.type) {
337
  case 'session_started':
 
645
 
646
  this.isStreaming = false;
647
 
648
+ if (this.reconnectTimeout) {
649
+ clearTimeout(this.reconnectTimeout);
650
+ this.reconnectTimeout = null;
651
+ }
652
+
653
+ // Stop the render loop
654
  this._stopRenderLoop();
655
  this._lastDetection = null;
656
 
657
+ // Stop frame capture
658
  if (this.captureInterval) {
659
  clearInterval(this.captureInterval);
660
  this.captureInterval = null;
661
  }
662
 
663
+ // Send the end-session request and wait for the response
664
  if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sessionId) {
665
  const sessionId = this.sessionId;
666
 
667
+ // Wait for the session_ended message
668
  const waitForSessionEnd = new Promise((resolve) => {
669
  const originalHandler = this.ws.onmessage;
670
  const timeout = setTimeout(() => {
 
682
  this.ws.onmessage = originalHandler;
683
  resolve();
684
  } else {
685
+ // Continue handling non-terminal messages
686
  this.handleServerMessage(data);
687
  }
688
  } catch (e) {
 
697
  session_id: sessionId
698
  }));
699
 
700
+ // Wait for the response or a timeout
701
  await waitForSessionEnd;
702
  }
703
 
704
+ // Delay socket shutdown briefly so pending messages can flush
705
  await new Promise(resolve => setTimeout(resolve, 200));
706
 
707
+ // Close the WebSocket
708
  if (this.ws) {
709
  this.ws.close();
710
  this.ws = null;
711
  }
712
 
713
+ // Stop the camera
714
  if (this.stream) {
715
  this.stream.getTracks().forEach(track => track.stop());
716
  this.stream = null;
717
  }
718
 
719
+ // Clear the video element
720
  if (this.localVideoElement) {
721
  this.localVideoElement.srcObject = null;
722
  }
723
 
724
+ // Clear the canvas
725
  if (this.displayCanvas) {
726
  const ctx = this.displayCanvas.getContext('2d');
727
  ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
728
  }
729
 
730
+ // Reset transient state
731
  this.unfocusedStartTime = null;
732
  this.lastNotificationTime = null;
733
 
 
739
  this.frameRate = Math.max(10, Math.min(30, rate));
740
  console.log(`Frame rate set to ${this.frameRate} FPS`);
741
 
742
+ // Restart capture if streaming is already active
743
  if (this.isStreaming && this.captureInterval) {
744
  clearInterval(this.captureInterval);
745
  this.startCapture();
ui/pipeline.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import collections
2
  import glob
3
  import json
 
1
+ from __future__ import annotations
2
+
3
  import collections
4
  import glob
5
  import json
vite.config.js CHANGED
@@ -1,19 +1,27 @@
1
- import { defineConfig } from 'vite'
2
  import react from '@vitejs/plugin-react'
3
 
4
- export default defineConfig({
5
- plugins: [react()],
6
- server: {
7
- proxy: {
8
- '/api': {
9
- target: 'http://localhost:8000',
10
- changeOrigin: true,
11
- secure: false,
12
- },
13
- '/ws': {
14
- target: 'ws://localhost:8000',
15
- ws: true,
 
 
 
 
 
 
 
 
16
  }
17
  }
18
  }
19
- })
 
1
+ import { defineConfig, loadEnv } from 'vite'
2
  import react from '@vitejs/plugin-react'
3
 
4
+ export default defineConfig(({ mode }) => {
5
+ const env = loadEnv(mode, process.cwd(), '')
6
+ const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:7860'
7
+ const backendWsUrl = backendUrl.replace(/^http/, 'ws')
8
+
9
+ return {
10
+ plugins: [react()],
11
+ server: {
12
+ proxy: {
13
+ '/api': {
14
+ target: backendUrl,
15
+ changeOrigin: true,
16
+ secure: false,
17
+ },
18
+ '/ws': {
19
+ target: backendWsUrl,
20
+ ws: true,
21
+ changeOrigin: true,
22
+ secure: false,
23
+ }
24
  }
25
  }
26
  }
27
+ })