major refactoring

This commit is contained in:
2025-10-06 11:21:03 +02:00
parent 5f18aab285
commit 0019644e61
5 changed files with 1231 additions and 656 deletions

View File

@ -1,22 +1,28 @@
# Run in dev mode using:
# fastapi dev app.py
#
# After starting the server, you can navigate to http://localhost:8000 to see the web interface.
# This will automatically start both the web server AND the UDP relay (server.py).
# After starting, navigate to http://localhost:8000 to see the web interface.
#
# Note: This requires the user to have the fastapi CLI tool installed.
# The user should be in the same directory as `app.py` as well as `index.html`.
# The user should be in the same directory as `app.py`, `server.py`, and `index.html`.
import asyncio
from contextlib import asynccontextmanager
import socket
import subprocess
import os
import signal
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
clients = set()
server_process = None # Will hold the server.py subprocess
# Broadcast function to notify all SSE clients
async def notify_clients(message: str):
@ -35,7 +41,7 @@ async def sock_recvfrom(nonblocking_sock, *pos, loop, **kw):
finally:
loop.remove_reader(nonblocking_sock.fileno())
# Background task: UDP listener
# Background task: UDP listener for SSE clients
async def udp_listener():
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@ -48,13 +54,100 @@ async def udp_listener():
message = data.decode()
await notify_clients(message)
# Background task: Tracking data listener
async def tracking_listener():
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", 5002)) # Different port for tracking data
sock.setblocking(False)
while True:
data, addr = await sock_recvfrom(sock, 1024 * 16, loop=loop) # Larger buffer for tracking data
# Only record if tracking is active
if tracking_state["is_recording"]:
try:
message = data.decode()
# Extract source IP from tagged data (added by server.py)
if message.startswith("SOURCE_IP:"):
parts = message.split("|", 1)
source_ip = parts[0].replace("SOURCE_IP:", "")
actual_data = parts[1] if len(parts) > 1 else ""
else:
# Fallback if data isn't tagged (shouldn't happen normally)
source_ip = addr[0]
actual_data = message
# Only record data from the active player
if source_ip == tracking_state["active_player_ip"]:
parsed = parse_tracking_data(actual_data)
if parsed:
import time
# Calculate time elapsed since experiment start
elapsed_time = time.time() - tracking_state["experiment_start_time"]
# Add sample with metadata
sample = {
"timestamp": time.time(),
"elapsed_time": elapsed_time,
"current_word": current_word_state["word"],
"word_time_remaining": 0.0,
"data": parsed
}
# Calculate word time remaining if word is active
if current_word_state["startTime"]:
word_elapsed = time.time() - current_word_state["startTime"]
sample["word_time_remaining"] = max(0, current_word_state["timeSeconds"] - word_elapsed)
tracking_state["samples"].append(sample)
except Exception as e:
print(f"Error processing tracking data: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
global server_process
# Start server.py subprocess
print("Starting UDP relay server (server.py)...")
try:
server_process = subprocess.Popen(
["python", "server.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=os.path.dirname(os.path.abspath(__file__))
)
print(f"UDP relay server started with PID {server_process.pid}")
except Exception as e:
print(f"Warning: Failed to start server.py: {e}")
print("You may need to start server.py manually.")
# Start background tasks
asyncio.create_task(udp_listener())
asyncio.create_task(tracking_listener())
yield
# Cleanup: Stop server.py subprocess
if server_process:
print("Stopping UDP relay server...")
server_process.terminate()
try:
server_process.wait(timeout=5)
print("UDP relay server stopped")
except subprocess.TimeoutExpired:
print("Forcefully killing UDP relay server...")
server_process.kill()
server_process.wait()
app = FastAPI(lifespan=lifespan)
# Mount static files for serving CSS and JavaScript
app.mount("/static", StaticFiles(directory="."), name="static")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# sock.bind(("0.0.0.0", 5002))
@ -86,6 +179,17 @@ class Word(BaseModel):
class WordList(BaseModel):
words: list[str]
class VRConfig(BaseModel):
player1_ip: str
player2_ip: str
server_ip: str
mode: str
class TrackingStart(BaseModel):
group_id: str
condition: str
active_player_ip: str
# Global state for current word display
current_word_state = {
"word": "",
@ -94,6 +198,97 @@ current_word_state = {
"startTime": None
}
# Global state for tracking data recording
tracking_state = {
"is_recording": False,
"group_id": "",
"condition": "",
"active_player_ip": "",
"experiment_start_time": None,
"samples": [] # List of tracking data samples
}
def parse_tracking_data(data_str):
"""
Parse tracking data from VR headset to extract camera and controller positions/rotations.
Returns dict with center_eye, left_hand, right_hand data or None if parsing fails.
"""
try:
parts = data_str.split(';')
# The data structure from TrackWeights.cs:
# 0-62: Face expressions (63 values)
# 63-66: Root orientation (4 values)
# 67-69: Root position (3 values)
# 70: Root scale (1 value)
# 71: Bone rotations length (1 value)
bone_rot_length = int(float(parts[71]))
# 72 to 72+(bone_rot_length*4)-1: Bone rotations (4 values each)
idx = 72 + (bone_rot_length * 4)
# idx: IsDataValid
idx += 1
# idx: IsDataHighConfidence
idx += 1
# idx: Bone translations length
bone_trans_length = int(float(parts[idx]))
idx += 1
# idx to idx+(bone_trans_length*3)-1: Bone translations (3 values each)
idx += bone_trans_length * 3
# idx: SkeletonChangedCount
idx += 1
# idx to idx+3: Left eye rotation (4 values)
idx += 4
# idx to idx+3: Right eye rotation (4 values)
idx += 4
# Now we're at the data we want!
# Center eye camera: position (3) + rotation (4) = 7 values
center_eye_pos_x = float(parts[idx])
center_eye_pos_y = float(parts[idx + 1])
center_eye_pos_z = float(parts[idx + 2])
center_eye_rot_w = float(parts[idx + 3])
center_eye_rot_x = float(parts[idx + 4])
center_eye_rot_y = float(parts[idx + 5])
center_eye_rot_z = float(parts[idx + 6])
idx += 7
# Left hand controller: position (3) + rotation (4) = 7 values
left_hand_pos_x = float(parts[idx])
left_hand_pos_y = float(parts[idx + 1])
left_hand_pos_z = float(parts[idx + 2])
left_hand_rot_w = float(parts[idx + 3])
left_hand_rot_x = float(parts[idx + 4])
left_hand_rot_y = float(parts[idx + 5])
left_hand_rot_z = float(parts[idx + 6])
idx += 7
# Right hand controller: position (3) + rotation (4) = 7 values
right_hand_pos_x = float(parts[idx])
right_hand_pos_y = float(parts[idx + 1])
right_hand_pos_z = float(parts[idx + 2])
right_hand_rot_w = float(parts[idx + 3])
right_hand_rot_x = float(parts[idx + 4])
right_hand_rot_y = float(parts[idx + 5])
right_hand_rot_z = float(parts[idx + 6])
return {
"center_eye": {
"pos": {"x": center_eye_pos_x, "y": center_eye_pos_y, "z": center_eye_pos_z},
"rot": {"w": center_eye_rot_w, "x": center_eye_rot_x, "y": center_eye_rot_y, "z": center_eye_rot_z}
},
"left_hand": {
"pos": {"x": left_hand_pos_x, "y": left_hand_pos_y, "z": left_hand_pos_z},
"rot": {"w": left_hand_rot_w, "x": left_hand_rot_x, "y": left_hand_rot_y, "z": left_hand_rot_z}
},
"right_hand": {
"pos": {"x": right_hand_pos_x, "y": right_hand_pos_y, "z": right_hand_pos_z},
"rot": {"w": right_hand_rot_w, "x": right_hand_rot_x, "y": right_hand_rot_y, "z": right_hand_rot_z}
}
}
except (IndexError, ValueError) as e:
print(f"Error parsing tracking data: {e}")
return None
@app.post("/word")
def read_word(word: Word):
import time
@ -140,6 +335,150 @@ def shuffle_words(word_list: WordList):
random.shuffle(shuffled)
return { "status": "ok", "shuffled_words": shuffled }
@app.post("/send-config")
def send_vr_config(config: VRConfig):
"""
Send IP and MODE configuration to VR headsets.
This integrates the functionality from control.py into the web interface.
"""
try:
# Create UDP socket for sending commands
cmd_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Send IP configuration to both players
ip_msg = f"IP:{config.server_ip}".encode('utf-8')
cmd_sock.sendto(ip_msg, (config.player1_ip, 5000))
cmd_sock.sendto(ip_msg, (config.player2_ip, 5000))
# Send MODE configuration to both players
mode_msg = f"MODE:{config.mode}".encode('utf-8')
cmd_sock.sendto(mode_msg, (config.player1_ip, 5000))
cmd_sock.sendto(mode_msg, (config.player2_ip, 5000))
cmd_sock.close()
print(f"Sent IP config: {ip_msg.decode()}")
print(f"Sent MODE config: {mode_msg.decode()}")
print(f"To players: {config.player1_ip}, {config.player2_ip}")
return { "status": "ok" }
except Exception as e:
print(f"Error sending VR config: {e}")
return { "status": "error", "message": str(e) }
@app.post("/tracking/start")
def start_tracking(config: TrackingStart):
"""
Start recording tracking data from the active player.
"""
import time
tracking_state["is_recording"] = True
tracking_state["group_id"] = config.group_id
tracking_state["condition"] = config.condition
tracking_state["active_player_ip"] = config.active_player_ip
tracking_state["experiment_start_time"] = time.time()
tracking_state["samples"] = []
print(f"Started tracking: group={config.group_id}, condition={config.condition}, player={config.active_player_ip}")
return { "status": "ok", "message": "Tracking started" }
@app.post("/tracking/stop")
def stop_tracking():
"""
Stop recording tracking data.
"""
tracking_state["is_recording"] = False
sample_count = len(tracking_state["samples"])
print(f"Stopped tracking: {sample_count} samples recorded")
return { "status": "ok", "message": f"Tracking stopped. {sample_count} samples recorded." }
@app.get("/tracking/status")
def get_tracking_status():
"""
Get current tracking status.
"""
return {
"is_recording": tracking_state["is_recording"],
"sample_count": len(tracking_state["samples"]),
"group_id": tracking_state["group_id"],
"condition": tracking_state["condition"]
}
@app.get("/tracking/download")
def download_tracking_csv():
"""
Download tracking data as CSV.
"""
import io
from datetime import datetime
if len(tracking_state["samples"]) == 0:
return { "status": "error", "message": "No tracking data available" }
# Create CSV content
output = io.StringIO()
# Write header
header = [
"timestamp", "elapsed_time", "group_id", "condition", "current_word", "word_time_remaining",
"center_eye_pos_x", "center_eye_pos_y", "center_eye_pos_z",
"center_eye_rot_w", "center_eye_rot_x", "center_eye_rot_y", "center_eye_rot_z",
"left_hand_pos_x", "left_hand_pos_y", "left_hand_pos_z",
"left_hand_rot_w", "left_hand_rot_x", "left_hand_rot_y", "left_hand_rot_z",
"right_hand_pos_x", "right_hand_pos_y", "right_hand_pos_z",
"right_hand_rot_w", "right_hand_rot_x", "right_hand_rot_y", "right_hand_rot_z"
]
output.write(";".join(header) + "\n")
# Write data rows
for sample in tracking_state["samples"]:
data = sample["data"]
row = [
str(sample["timestamp"]),
f"{sample['elapsed_time']:.4f}",
tracking_state["group_id"],
tracking_state["condition"],
sample["current_word"],
f"{sample['word_time_remaining']:.4f}",
f"{data['center_eye']['pos']['x']:.4f}",
f"{data['center_eye']['pos']['y']:.4f}",
f"{data['center_eye']['pos']['z']:.4f}",
f"{data['center_eye']['rot']['w']:.4f}",
f"{data['center_eye']['rot']['x']:.4f}",
f"{data['center_eye']['rot']['y']:.4f}",
f"{data['center_eye']['rot']['z']:.4f}",
f"{data['left_hand']['pos']['x']:.4f}",
f"{data['left_hand']['pos']['y']:.4f}",
f"{data['left_hand']['pos']['z']:.4f}",
f"{data['left_hand']['rot']['w']:.4f}",
f"{data['left_hand']['rot']['x']:.4f}",
f"{data['left_hand']['rot']['y']:.4f}",
f"{data['left_hand']['rot']['z']:.4f}",
f"{data['right_hand']['pos']['x']:.4f}",
f"{data['right_hand']['pos']['y']:.4f}",
f"{data['right_hand']['pos']['z']:.4f}",
f"{data['right_hand']['rot']['w']:.4f}",
f"{data['right_hand']['rot']['x']:.4f}",
f"{data['right_hand']['rot']['y']:.4f}",
f"{data['right_hand']['rot']['z']:.4f}"
]
output.write(";".join(row) + "\n")
csv_content = output.getvalue()
output.close()
# Create filename with timestamp
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"{tracking_state['group_id']}_tracking_{timestamp}.csv"
from fastapi.responses import Response
return Response(
content=csv_content,
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
# SSE endpoint
@app.get("/news")

View File

@ -5,345 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VR Charades Experiment Control</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #f5f5f5;
color: #2c3e50;
line-height: 1.5;
}
.header {
background: #2c3e50;
color: white;
padding: 1.5rem 2rem;
border-bottom: 3px solid #34495e;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header p {
font-size: 0.9rem;
color: #bdc3c7;
margin-top: 0.25rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.timer-section {
background: white;
border: 2px solid #34495e;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.timer-label {
font-size: 0.875rem;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.timer-display {
font-family: 'Roboto Mono', monospace;
font-size: 4rem;
font-weight: 500;
color: #2c3e50;
}
.timer-display.warning {
color: #e67e22;
}
.timer-display.danger {
color: #e74c3c;
}
.timer-display.inactive {
color: #bdc3c7;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid #ecf0f1;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #34495e;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 3px;
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f8f9fa;
color: #adb5bd;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.radio-group {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.radio-label input {
cursor: pointer;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 3px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #229954;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #7f8c8d;
}
.btn-group {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.controls-section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.controls-section .btn-group {
margin-top: 0;
}
textarea {
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
}
.word-display {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.word-list-container {
max-height: 500px;
overflow-y: auto;
margin-top: 1rem;
}
.word-item {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 3px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s;
}
.word-item:hover {
background: #f0f0f0;
}
.word-item.active {
background: #e3f2fd;
border-color: #3498db;
}
.word-item.correct {
background: #e8f5e9;
border-color: #27ae60;
}
.word-item.incorrect {
background: #ffebee;
border-color: #e74c3c;
}
.word-content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.word-index {
font-family: 'Roboto Mono', monospace;
font-size: 0.8125rem;
color: #7f8c8d;
min-width: 2.5rem;
}
.word-text {
font-size: 0.9375rem;
color: #2c3e50;
}
.word-timer {
font-family: 'Roboto Mono', monospace;
font-size: 1rem;
color: #34495e;
min-width: 4rem;
text-align: right;
}
.word-checkbox {
margin-left: 1rem;
}
.placeholder {
text-align: center;
padding: 3rem 1rem;
color: #95a5a6;
}
.small-text {
font-size: 0.8125rem;
color: #7f8c8d;
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.main-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.timer-display {
font-size: 3rem;
}
.btn-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="header">
@ -411,6 +73,36 @@
</div>
</div>
<!-- VR Headset Configuration -->
<div class="section">
<h2 class="section-title">VR Headset Configuration</h2>
<div class="form-group">
<label class="form-label">Server IP Address</label>
<input type="text" id="server-ip" class="form-input" placeholder="10.42.0.1">
<div class="small-text">IP address that VR headsets should connect to</div>
</div>
<div class="form-group">
<label class="form-label">Experiment Mode</label>
<select id="experiment-mode" class="form-input">
<option value="1;1;1;0">Dynamic Face</option>
<option value="0;0;0;1">Dynamic Hands</option>
<option value="1;1;1;1" selected>Dynamic Hands+Face</option>
<option value="1;0;0;0">Static Face</option>
<option value="0;0;0;1">Static Hands</option>
<option value="1;0;0;1">Static Hands+Face</option>
</select>
<div class="small-text">Controls which body parts are shown and interaction method</div>
</div>
<div class="btn-group">
<button id="btn-send-config" class="btn btn-warning">Send Configuration to Headsets</button>
</div>
<div id="config-status" class="status-message" style="display: none;"></div>
</div>
<!-- Word List -->
<div class="section">
<h2 class="section-title">Word List</h2>
@ -431,6 +123,10 @@
<div class="btn-group">
<button id="btn-stop" class="btn btn-danger" disabled>Stop Experiment</button>
<button id="btn-save" class="btn btn-primary" disabled>Save Results (CSV)</button>
<button id="btn-download-tracking" class="btn btn-primary">Download Tracking Data (CSV)</button>
</div>
<div class="small-text" style="margin-top: 1rem;">
Tracking data is automatically recorded during experiments and includes camera and controller positions/rotations at 60Hz.
</div>
</div>
@ -446,319 +142,6 @@
</div>
</div>
<script>
let runningWordIndex = -1;
let runningWordList = [];
let experimentStartTime = null;
let totalDurationMs = 0;
let frameId = undefined;
let last = undefined;
let newWord;
let lastWordStatus = -1;
let experimentRunning = false;
function updateButtonStates() {
document.getElementById("btn-stop").disabled = !experimentRunning;
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
document.getElementById("btn-start").disabled = experimentRunning;
document.getElementById("btn-shuffle").disabled = experimentRunning;
}
function createWordItems() {
const wordList = document.getElementById("word-list");
const text = wordList.value;
const display = document.getElementById("word-list-display");
display.innerHTML = "";
runningWordList = [];
runningWordIndex = -1;
const words = text.trim().split('\n').filter(w => w.trim());
words.forEach((word, index) => {
const div = document.createElement("div");
div.classList.add("word-item");
div.setAttribute("data-word", word.trim());
div.setAttribute("data-index", index);
const content = document.createElement("div");
content.classList.add("word-content");
const indexSpan = document.createElement("span");
indexSpan.classList.add("word-index");
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
const textSpan = document.createElement("span");
textSpan.classList.add("word-text");
textSpan.textContent = word.trim();
content.appendChild(indexSpan);
content.appendChild(textSpan);
const timer = document.createElement("div");
timer.classList.add("word-timer");
const timeSeconds = parseFloat(document.getElementById("time-s").value);
timer.textContent = timeSeconds.toFixed(1) + "s";
timer.setAttribute("data-remaining", timeSeconds);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.classList.add("word-checkbox");
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
div.classList.add("correct");
div.classList.remove("incorrect");
} else {
div.classList.remove("correct");
}
});
div.appendChild(content);
div.appendChild(timer);
div.appendChild(checkbox);
display.appendChild(div);
runningWordList.push(div);
});
}
function step(timestamp) {
if (last === undefined) {
last = timestamp;
experimentStartTime = timestamp;
runningWordIndex = 0;
newWord = true;
lastWordStatus = -1;
}
const elapsed = timestamp - last;
last = timestamp;
// Update total timer
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const totalElapsed = timestamp - experimentStartTime;
const remainingMs = totalDurationMs - totalElapsed;
const remainingMinutes = Math.floor(remainingMs / 60000);
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
timerDisplay.textContent =
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
if (remainingMs < 60000) {
timerDisplay.classList.add("danger");
} else if (remainingMs < 120000) {
timerDisplay.classList.add("warning");
}
if (totalElapsed >= totalDurationMs) {
stopExperiment();
alert("Experiment completed: Total duration reached");
return;
}
}
if (runningWordIndex >= runningWordList.length) {
stopExperiment();
return;
}
const currentWordDiv = runningWordList[runningWordIndex];
const timer = currentWordDiv.querySelector(".word-timer");
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
if (newWord) {
newWord = false;
const word = currentWordDiv.getAttribute("data-word");
sendNewWord(word, lastWordStatus, remainingTime);
// Remove active class from all
runningWordList.forEach(div => div.classList.remove("active"));
currentWordDiv.classList.add("active");
}
if (checkbox.checked || remainingTime < 0) {
if (remainingTime < 0) remainingTime = 0;
lastWordStatus = checkbox.checked ? 1 : 0;
timer.textContent = remainingTime.toFixed(1) + "s";
currentWordDiv.classList.remove("active");
if (checkbox.checked) {
currentWordDiv.classList.add("correct");
} else {
currentWordDiv.classList.add("incorrect");
}
runningWordIndex++;
newWord = true;
} else {
timer.textContent = remainingTime.toFixed(1) + "s";
timer.setAttribute("data-remaining", remainingTime);
}
if (runningWordIndex < runningWordList.length) {
frameId = requestAnimationFrame(step);
} else {
stopExperiment();
}
}
function stopExperiment() {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
experimentRunning = false;
updateButtonStates();
console.log("Experiment stopped");
}
function sendNewWord(word, lastWordStatus, timeSeconds) {
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: target,
lastWordStatus: lastWordStatus,
timeSeconds: timeSeconds,
word: word,
}),
});
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: targetOther,
lastWordStatus: lastWordStatus,
timeSeconds: 0.0,
word: "",
}),
});
}
// Event Listeners
document.getElementById("btn-start").addEventListener("click", () => {
const groupId = document.getElementById("group-id").value;
if (!groupId || !groupId.trim()) {
alert("Please enter a Group ID before starting.");
return;
}
const wordList = document.getElementById("word-list").value;
if (!wordList.trim()) {
alert("Please enter a word list before starting.");
return;
}
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
totalDurationMs = totalDurationMin * 60 * 1000;
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const minutes = Math.floor(totalDurationMs / 60000);
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
timerDisplay.textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
} else {
timerDisplay.textContent = "∞";
timerDisplay.className = "timer-display";
}
createWordItems();
if (frameId) cancelAnimationFrame(frameId);
last = undefined;
newWord = undefined;
experimentStartTime = null;
experimentRunning = true;
updateButtonStates();
frameId = requestAnimationFrame(step);
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min`);
});
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
document.getElementById("btn-save").addEventListener("click", () => {
if (runningWordList.length === 0) {
alert("No data to save. Please run the experiment first.");
return;
}
const groupId = document.getElementById("group-id").value || "group";
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
let data = "word;correct;time_left\n";
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
for (let i = 0; i < maxIndex; i++) {
const div = runningWordList[i];
const word = div.getAttribute("data-word");
const checkbox = div.querySelector('input[type="checkbox"]');
const timer = div.querySelector(".word-timer");
const isCorrect = checkbox.checked;
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
data += `${word};${isCorrect};${timeLeft}\n`;
}
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.href = url;
link.download = `${groupId}_results_${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
});
document.getElementById("btn-shuffle").addEventListener("click", async () => {
const wordList = document.getElementById("word-list");
const text = wordList.value.trim();
if (!text) {
alert("Please enter words to shuffle first.");
return;
}
const words = text.split('\n').map(word => word.trim()).filter(word => word);
try {
const response = await fetch("/shuffle", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ words: words }),
});
const result = await response.json();
if (result.status === "ok") {
wordList.value = result.shuffled_words.join('\n');
}
} catch (error) {
console.error("Error shuffling words:", error);
alert("Failed to shuffle words. Please try again.");
}
});
// Initialize button states
updateButtonStates();
</script>
<script src="/static/script.js"></script>
</body>
</html>

View File

@ -0,0 +1,479 @@
// Global state variables
let runningWordIndex = -1;
let runningWordList = [];
let experimentStartTime = null;
let totalDurationMs = 0;
let frameId = undefined;
let last = undefined;
let newWord;
let lastWordStatus = -1;
let experimentRunning = false;
// Update button states based on experiment status
function updateButtonStates() {
document.getElementById("btn-stop").disabled = !experimentRunning;
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
document.getElementById("btn-start").disabled = experimentRunning;
document.getElementById("btn-shuffle").disabled = experimentRunning;
}
// Create word items from word list
function createWordItems() {
const wordList = document.getElementById("word-list");
const text = wordList.value;
const display = document.getElementById("word-list-display");
display.innerHTML = "";
runningWordList = [];
runningWordIndex = -1;
const words = text.trim().split('\n').filter(w => w.trim());
words.forEach((word, index) => {
const div = document.createElement("div");
div.classList.add("word-item");
div.setAttribute("data-word", word.trim());
div.setAttribute("data-index", index);
const content = document.createElement("div");
content.classList.add("word-content");
const indexSpan = document.createElement("span");
indexSpan.classList.add("word-index");
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
const textSpan = document.createElement("span");
textSpan.classList.add("word-text");
textSpan.textContent = word.trim();
content.appendChild(indexSpan);
content.appendChild(textSpan);
const timer = document.createElement("div");
timer.classList.add("word-timer");
const timeSeconds = parseFloat(document.getElementById("time-s").value);
timer.textContent = timeSeconds.toFixed(1) + "s";
timer.setAttribute("data-remaining", timeSeconds);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.classList.add("word-checkbox");
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
div.classList.add("correct");
div.classList.remove("incorrect");
} else {
div.classList.remove("correct");
}
});
div.appendChild(content);
div.appendChild(timer);
div.appendChild(checkbox);
display.appendChild(div);
runningWordList.push(div);
});
}
// Main animation loop
function step(timestamp) {
if (last === undefined) {
last = timestamp;
experimentStartTime = timestamp;
runningWordIndex = 0;
newWord = true;
lastWordStatus = -1;
}
const elapsed = timestamp - last;
last = timestamp;
// Update total timer
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const totalElapsed = timestamp - experimentStartTime;
const remainingMs = totalDurationMs - totalElapsed;
const remainingMinutes = Math.floor(remainingMs / 60000);
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
timerDisplay.textContent =
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
if (remainingMs < 60000) {
timerDisplay.classList.add("danger");
} else if (remainingMs < 120000) {
timerDisplay.classList.add("warning");
}
if (totalElapsed >= totalDurationMs) {
stopExperiment();
alert("Experiment completed: Total duration reached");
return;
}
}
if (runningWordIndex >= runningWordList.length) {
stopExperiment();
return;
}
const currentWordDiv = runningWordList[runningWordIndex];
const timer = currentWordDiv.querySelector(".word-timer");
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
if (newWord) {
newWord = false;
const word = currentWordDiv.getAttribute("data-word");
sendNewWord(word, lastWordStatus, remainingTime);
// Remove active class from all
runningWordList.forEach(div => div.classList.remove("active"));
currentWordDiv.classList.add("active");
}
if (checkbox.checked || remainingTime < 0) {
if (remainingTime < 0) remainingTime = 0;
lastWordStatus = checkbox.checked ? 1 : 0;
timer.textContent = remainingTime.toFixed(1) + "s";
currentWordDiv.classList.remove("active");
if (checkbox.checked) {
currentWordDiv.classList.add("correct");
} else {
currentWordDiv.classList.add("incorrect");
}
runningWordIndex++;
newWord = true;
} else {
timer.textContent = remainingTime.toFixed(1) + "s";
timer.setAttribute("data-remaining", remainingTime);
}
if (runningWordIndex < runningWordList.length) {
frameId = requestAnimationFrame(step);
} else {
stopExperiment();
}
}
// Stop the experiment
function stopExperiment() {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
experimentRunning = false;
updateButtonStates();
// Stop tracking recording
stopTracking();
console.log("Experiment stopped");
}
// Start tracking recording
async function startTracking(groupId, condition, activePlayerIp) {
try {
const response = await fetch("/tracking/start", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
group_id: groupId,
condition: condition,
active_player_ip: activePlayerIp
}),
});
const result = await response.json();
console.log("Tracking started:", result.message);
} catch (error) {
console.error("Error starting tracking:", error);
}
}
// Stop tracking recording
async function stopTracking() {
try {
const response = await fetch("/tracking/stop", {
method: "POST",
});
const result = await response.json();
console.log("Tracking stopped:", result.message);
} catch (error) {
console.error("Error stopping tracking:", error);
}
}
// Download tracking data
async function downloadTracking() {
try {
const response = await fetch("/tracking/download");
if (response.ok) {
// Get filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition');
let filename = 'tracking_data.csv';
if (disposition && disposition.includes('filename=')) {
filename = disposition.split('filename=')[1].replace(/"/g, '');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log("Tracking data downloaded");
} else {
const result = await response.json();
alert("Error: " + (result.message || "Failed to download tracking data"));
}
} catch (error) {
console.error("Error downloading tracking:", error);
alert("Failed to download tracking data: " + error.message);
}
}
// Send word to VR headsets
function sendNewWord(word, lastWordStatus, timeSeconds) {
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: target,
lastWordStatus: lastWordStatus,
timeSeconds: timeSeconds,
word: word,
}),
});
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: targetOther,
lastWordStatus: lastWordStatus,
timeSeconds: 0.0,
word: "",
}),
});
}
// Send VR headset configuration (IP and MODE)
async function sendVRConfig() {
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const mode = document.getElementById("experiment-mode").value;
const serverIp = document.getElementById("server-ip").value;
if (!ipPlayer1 || !ipPlayer2) {
showConfigStatus("Please enter both player IP addresses", "error");
return;
}
if (!serverIp) {
showConfigStatus("Please enter the server IP address", "error");
return;
}
try {
const response = await fetch("/send-config", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player1_ip: ipPlayer1,
player2_ip: ipPlayer2,
server_ip: serverIp,
mode: mode,
}),
});
const result = await response.json();
if (result.status === "ok") {
showConfigStatus(`Configuration sent successfully! Mode: ${getModeDescription(mode)}`, "success");
} else {
showConfigStatus("Failed to send configuration", "error");
}
} catch (error) {
console.error("Error sending configuration:", error);
showConfigStatus("Error sending configuration: " + error.message, "error");
}
}
// Get human-readable mode description
function getModeDescription(mode) {
const modes = {
"1;1;1;0": "Dynamic Face",
"0;0;0;1": "Dynamic Hands",
"1;1;1;1": "Dynamic Hands+Face",
"1;0;0;0": "Static Face",
"0;0;0;1": "Static Hands",
"1;0;0;1": "Static Hands+Face"
};
return modes[mode] || mode;
}
// Show configuration status message
function showConfigStatus(message, type) {
const statusDiv = document.getElementById("config-status");
statusDiv.textContent = message;
statusDiv.className = `status-message ${type}`;
statusDiv.style.display = "block";
// Auto-hide after 5 seconds
setTimeout(() => {
statusDiv.style.display = "none";
}, 5000);
}
// Event Listeners
document.addEventListener("DOMContentLoaded", () => {
// Start Experiment
document.getElementById("btn-start").addEventListener("click", () => {
const groupId = document.getElementById("group-id").value;
if (!groupId || !groupId.trim()) {
alert("Please enter a Group ID before starting.");
return;
}
const wordList = document.getElementById("word-list").value;
if (!wordList.trim()) {
alert("Please enter a word list before starting.");
return;
}
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
totalDurationMs = totalDurationMin * 60 * 1000;
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const minutes = Math.floor(totalDurationMs / 60000);
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
timerDisplay.textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
} else {
timerDisplay.textContent = "∞";
timerDisplay.className = "timer-display";
}
createWordItems();
if (frameId) cancelAnimationFrame(frameId);
last = undefined;
newWord = undefined;
experimentStartTime = null;
experimentRunning = true;
updateButtonStates();
// Start tracking recording
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const activePlayerIp = isPlayer1 ? ipPlayer1 : ipPlayer2;
const condition = document.getElementById("experiment-mode").value;
const conditionName = getModeDescription(condition);
startTracking(groupId, conditionName, activePlayerIp);
frameId = requestAnimationFrame(step);
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min, Condition=${conditionName}`);
});
// Stop Experiment
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
// Save Results
document.getElementById("btn-save").addEventListener("click", () => {
if (runningWordList.length === 0) {
alert("No data to save. Please run the experiment first.");
return;
}
const groupId = document.getElementById("group-id").value || "group";
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
let data = "word;correct;time_left\n";
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
for (let i = 0; i < maxIndex; i++) {
const div = runningWordList[i];
const word = div.getAttribute("data-word");
const checkbox = div.querySelector('input[type="checkbox"]');
const timer = div.querySelector(".word-timer");
const isCorrect = checkbox.checked;
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
data += `${word};${isCorrect};${timeLeft}\n`;
}
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.href = url;
link.download = `${groupId}_results_${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
});
// Shuffle Words
document.getElementById("btn-shuffle").addEventListener("click", async () => {
const wordList = document.getElementById("word-list");
const text = wordList.value.trim();
if (!text) {
alert("Please enter words to shuffle first.");
return;
}
const words = text.split('\n').map(word => word.trim()).filter(word => word);
try {
const response = await fetch("/shuffle", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ words: words }),
});
const result = await response.json();
if (result.status === "ok") {
wordList.value = result.shuffled_words.join('\n');
}
} catch (error) {
console.error("Error shuffling words:", error);
alert("Failed to shuffle words. Please try again.");
}
});
// Send VR Configuration
document.getElementById("btn-send-config").addEventListener("click", sendVRConfig);
// Download Tracking Data
document.getElementById("btn-download-tracking").addEventListener("click", downloadTracking);
// Initialize button states
updateButtonStates();
});

View File

@ -30,7 +30,16 @@ def forward(source_socket):
label = "A→B" if addr == DEVICE1_ADDR else "B→A"
if addr != DEVICE1_ADDR and addr != DEVICE2_ADDR:
label = f"unknown {addr}"
# Forward to other player
sock_from_A.sendto(data, (target_ip, 5000))
# Also forward to app.py tracking listener on port 5002
# Prepend source IP so app.py can identify which player sent the data
source_ip = addr[0]
tagged_data = f"SOURCE_IP:{source_ip}|".encode('utf-8') + data
sock_from_A.sendto(tagged_data, ("127.0.0.1", 5002))
timestamp = datetime.now().strftime("%H:%M:%S")
# Logging

View File

@ -0,0 +1,365 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #f5f5f5;
color: #2c3e50;
line-height: 1.5;
}
.header {
background: #2c3e50;
color: white;
padding: 1.5rem 2rem;
border-bottom: 3px solid #34495e;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header p {
font-size: 0.9rem;
color: #bdc3c7;
margin-top: 0.25rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.timer-section {
background: white;
border: 2px solid #34495e;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.timer-label {
font-size: 0.875rem;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.timer-display {
font-family: 'Roboto Mono', monospace;
font-size: 4rem;
font-weight: 500;
color: #2c3e50;
}
.timer-display.warning {
color: #e67e22;
}
.timer-display.danger {
color: #e74c3c;
}
.timer-display.inactive {
color: #bdc3c7;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid #ecf0f1;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #34495e;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 3px;
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f8f9fa;
color: #adb5bd;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.radio-group {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.radio-label input {
cursor: pointer;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 3px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #229954;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #7f8c8d;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #e67e22;
}
.btn-group {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.controls-section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.controls-section .btn-group {
margin-top: 0;
}
textarea {
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
}
.word-display {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.word-list-container {
max-height: 500px;
overflow-y: auto;
margin-top: 1rem;
}
.word-item {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 3px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s;
}
.word-item:hover {
background: #f0f0f0;
}
.word-item.active {
background: #e3f2fd;
border-color: #3498db;
}
.word-item.correct {
background: #e8f5e9;
border-color: #27ae60;
}
.word-item.incorrect {
background: #ffebee;
border-color: #e74c3c;
}
.word-content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.word-index {
font-family: 'Roboto Mono', monospace;
font-size: 0.8125rem;
color: #7f8c8d;
min-width: 2.5rem;
}
.word-text {
font-size: 0.9375rem;
color: #2c3e50;
}
.word-timer {
font-family: 'Roboto Mono', monospace;
font-size: 1rem;
color: #34495e;
min-width: 4rem;
text-align: right;
}
.word-checkbox {
margin-left: 1rem;
}
.placeholder {
text-align: center;
padding: 3rem 1rem;
color: #95a5a6;
}
.small-text {
font-size: 0.8125rem;
color: #7f8c8d;
margin-top: 0.25rem;
}
.status-message {
padding: 0.75rem 1rem;
border-radius: 3px;
margin-top: 1rem;
font-size: 0.875rem;
}
.status-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 768px) {
.main-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.timer-display {
font-size: 3rem;
}
.btn-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}