mrfakename commited on
Commit
b23ed54
·
0 Parent(s):

metronome app

Browse files
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.egg-info/
3
+ .claude/
4
+ .DS_Store
README.md ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Metronome
3
+ emoji: 🎵
4
+ colorFrom: red
5
+ colorTo: blue
6
+ sdk: static
7
+ pinned: false
8
+ short_description: Metronome app for Reachy Mini
9
+ tags:
10
+ - reachy_mini
11
+ - reachy_mini_python_app
12
+ ---
index.html ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width" />
7
+ <title> Metronome </title>
8
+ <link rel="stylesheet" href="style.css" />
9
+ </head>
10
+
11
+ <body>
12
+ <div class="hero">
13
+ <div class="hero-content">
14
+ <div class="app-icon">🤖⚡</div>
15
+ <h1> Metronome </h1>
16
+ <p class="tagline">Enter your tagline here</p>
17
+ </div>
18
+ </div>
19
+
20
+ <div class="container">
21
+ <div class="main-card">
22
+ <div class="app-preview">
23
+ <div class="preview-image">
24
+ <div class="camera-feed">🛠️</div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="download-section">
31
+ <div class="download-card">
32
+ <h2>Install This App</h2>
33
+
34
+ <div class="dashboard-config">
35
+ <label for="dashboardUrl">Your Reachy Dashboard URL:</label>
36
+ <input type="url" id="dashboardUrl" value="http://localhost:8000"
37
+ placeholder="http://your-reachy-ip:8000" />
38
+ </div>
39
+
40
+ <button id="installBtn" class="install-btn primary">
41
+ <span class="btn-icon">📥</span>
42
+ Install Metronome to Reachy Mini
43
+ </button>
44
+
45
+ <div id="installStatus" class="install-status"></div>
46
+
47
+ </div>
48
+ </div>
49
+
50
+ <div class="footer">
51
+ <p>
52
+ 🤖 Metronome •
53
+ <a href="https://github.com/pollen-robotics" target="_blank">Pollen Robotics</a> •
54
+ <a href="https://huggingface.co/spaces/pollen-robotics/Reachy_Mini_Apps" target="_blank">Browse More
55
+ Apps</a>
56
+ </p>
57
+ </div>
58
+ </div>
59
+
60
+ <script>
61
+ // Get the current Hugging Face Space URL as the repository URL
62
+ function getCurrentSpaceUrl() {
63
+ // Get current page URL and convert to repository format
64
+ const currentUrl = window.location.href;
65
+
66
+ // Remove any trailing slashes and query parameters
67
+ const cleanUrl = currentUrl.split('?')[0].replace(/\/$/, '');
68
+
69
+ return cleanUrl;
70
+ }
71
+
72
+ // Parse TOML content to extract project name
73
+ function parseTomlProjectName(tomlContent) {
74
+ try {
75
+ const lines = tomlContent.split('\n');
76
+ let inProjectSection = false;
77
+
78
+ for (const line of lines) {
79
+ const trimmedLine = line.trim();
80
+
81
+ // Check if we're entering the [project] section
82
+ if (trimmedLine === '[project]') {
83
+ inProjectSection = true;
84
+ continue;
85
+ }
86
+
87
+ // Check if we're entering a different section
88
+ if (trimmedLine.startsWith('[') && trimmedLine !== '[project]') {
89
+ inProjectSection = false;
90
+ continue;
91
+ }
92
+
93
+ // If we're in the project section, look for the name field
94
+ if (inProjectSection && trimmedLine.startsWith('name')) {
95
+ const match = trimmedLine.match(/name\s*=\s*["']([^"']+)["']/);
96
+ if (match) {
97
+ // Convert to lowercase and replace invalid characters for app naming
98
+ return match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-');
99
+ }
100
+ }
101
+ }
102
+
103
+ throw new Error('Project name not found in pyproject.toml');
104
+ } catch (error) {
105
+ console.error('Error parsing pyproject.toml:', error);
106
+ return 'unknown-app';
107
+ }
108
+ }
109
+
110
+ // Fetch and parse pyproject.toml from the current space
111
+ async function getAppNameFromCurrentSpace() {
112
+ try {
113
+ // Fetch pyproject.toml from the current space
114
+ const response = await fetch('./pyproject.toml');
115
+ if (!response.ok) {
116
+ throw new Error(`Failed to fetch pyproject.toml: ${response.status}`);
117
+ }
118
+
119
+ const tomlContent = await response.text();
120
+ return parseTomlProjectName(tomlContent);
121
+ } catch (error) {
122
+ console.error('Error fetching app name from current space:', error);
123
+ // Fallback to extracting from URL if pyproject.toml is not accessible
124
+ const url = getCurrentSpaceUrl();
125
+ const parts = url.split('/');
126
+ const spaceName = parts[parts.length - 1];
127
+ return spaceName.toLowerCase().replace(/[^a-z0-9-_]/g, '-');
128
+ }
129
+ }
130
+
131
+ async function installToReachy() {
132
+ const dashboardUrl = document.getElementById('dashboardUrl').value.trim();
133
+ const statusDiv = document.getElementById('installStatus');
134
+ const installBtn = document.getElementById('installBtn');
135
+
136
+ if (!dashboardUrl) {
137
+ showStatus('error', 'Please enter your Reachy dashboard URL');
138
+ return;
139
+ }
140
+
141
+ try {
142
+ installBtn.disabled = true;
143
+ installBtn.innerHTML = '<span class="btn-icon">⏳</span>Installing...';
144
+ showStatus('loading', 'Connecting to your Reachy dashboard...');
145
+
146
+ // Test connection
147
+ const testResponse = await fetch(`${dashboardUrl}/api/status`, {
148
+ method: 'GET',
149
+ mode: 'cors',
150
+ });
151
+
152
+ if (!testResponse.ok) {
153
+ throw new Error('Cannot connect to dashboard. Make sure the URL is correct and the dashboard is running.');
154
+ }
155
+
156
+ showStatus('loading', 'Reading app configuration...');
157
+
158
+ // Get app name from pyproject.toml in current space
159
+ const appName = await getAppNameFromCurrentSpace();
160
+
161
+ // Get current space URL as repository URL
162
+ const repoUrl = getCurrentSpaceUrl();
163
+
164
+ showStatus('loading', `Starting installation of "${appName}"...`);
165
+
166
+ // Start installation
167
+ const installResponse = await fetch(`${dashboardUrl}/api/install`, {
168
+ method: 'POST',
169
+ mode: 'cors',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ },
173
+ body: JSON.stringify({
174
+ url: repoUrl,
175
+ name: appName
176
+ })
177
+ });
178
+
179
+ const result = await installResponse.json();
180
+
181
+ if (installResponse.ok) {
182
+ showStatus('success', `✅ Installation started for "${appName}"! Check your dashboard for progress.`);
183
+ setTimeout(() => {
184
+ showStatus('info', `Open your dashboard at ${dashboardUrl} to see the installed app.`);
185
+ }, 3000);
186
+ } else {
187
+ throw new Error(result.detail || 'Installation failed');
188
+ }
189
+
190
+ } catch (error) {
191
+ console.error('Installation error:', error);
192
+ showStatus('error', `❌ ${error.message}`);
193
+ } finally {
194
+ installBtn.disabled = false;
195
+ installBtn.innerHTML = '<span class="btn-icon">📥</span>Install App to Reachy';
196
+ }
197
+ }
198
+
199
+ function showStatus(type, message) {
200
+ const statusDiv = document.getElementById('installStatus');
201
+ statusDiv.className = `install-status ${type}`;
202
+ statusDiv.textContent = message;
203
+ statusDiv.style.display = 'block';
204
+ }
205
+
206
+ function copyToClipboard() {
207
+ const repoUrl = document.getElementById('repoUrl').textContent;
208
+ navigator.clipboard.writeText(repoUrl).then(() => {
209
+ showStatus('success', '📋 Repository URL copied to clipboard!');
210
+ }).catch(() => {
211
+ showStatus('error', 'Failed to copy URL. Please copy manually.');
212
+ });
213
+ }
214
+
215
+ // Update the displayed repository URL on page load
216
+ document.addEventListener('DOMContentLoaded', () => {
217
+ // Auto-detect local dashboard
218
+ const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
219
+ if (isLocalhost) {
220
+ document.getElementById('dashboardUrl').value = 'http://localhost:8000';
221
+ }
222
+
223
+ // Update the repository URL display if element exists
224
+ const repoUrlElement = document.getElementById('repoUrl');
225
+ if (repoUrlElement) {
226
+ repoUrlElement.textContent = getCurrentSpaceUrl();
227
+ }
228
+ });
229
+
230
+ // Event listeners
231
+ document.getElementById('installBtn').addEventListener('click', installToReachy);
232
+ </script>
233
+ </body>
234
+
235
+ </html>
metronome/__init__.py ADDED
File without changes
metronome/main.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import math
3
+ from pathlib import Path
4
+ from reachy_mini import ReachyMini, ReachyMiniApp
5
+ from reachy_mini.utils import create_head_pose
6
+ import numpy as np
7
+ import time
8
+ from pydantic import BaseModel
9
+ from typing import Optional
10
+
11
+ # Path to click sound
12
+ CLICK_SOUND = str(Path(__file__).parent / "static" / "click.wav")
13
+
14
+
15
+ class Metronome(ReachyMiniApp):
16
+ custom_app_url: str | None = "http://0.0.0.0:8082"
17
+
18
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
19
+ # Metronome state
20
+ bpm = 120
21
+ is_running = False
22
+ amplitude_deg = 35.0 # Antenna swing amplitude in degrees
23
+
24
+ # Beat tracking
25
+ beat_start_time = 0.0
26
+ current_beat = 0
27
+ play_tick = False
28
+
29
+ # API models
30
+ class MetronomeState(BaseModel):
31
+ bpm: Optional[int] = None
32
+ is_running: Optional[bool] = None
33
+ amplitude: Optional[float] = None
34
+
35
+ class TickRequest(BaseModel):
36
+ pass
37
+
38
+ @self.settings_app.get("/state")
39
+ def get_state():
40
+ return {
41
+ "bpm": bpm,
42
+ "is_running": is_running,
43
+ "amplitude": amplitude_deg,
44
+ "current_beat": current_beat
45
+ }
46
+
47
+ @self.settings_app.post("/state")
48
+ def update_state(state: MetronomeState):
49
+ nonlocal bpm, is_running, amplitude_deg, beat_start_time, current_beat
50
+
51
+ if state.bpm is not None:
52
+ bpm = max(40, min(240, state.bpm))
53
+
54
+ if state.is_running is not None:
55
+ is_running = state.is_running
56
+ if is_running:
57
+ beat_start_time = time.time()
58
+ current_beat = 0
59
+
60
+ if state.amplitude is not None:
61
+ amplitude_deg = max(10, min(50, state.amplitude))
62
+
63
+ return {
64
+ "bpm": bpm,
65
+ "is_running": is_running,
66
+ "amplitude": amplitude_deg,
67
+ "current_beat": current_beat
68
+ }
69
+
70
+ @self.settings_app.post("/tap")
71
+ def tap_tempo():
72
+ """Tap tempo - calculates BPM from tap intervals"""
73
+ nonlocal bpm
74
+ # This endpoint can be used for tap tempo feature
75
+ return {"bpm": bpm}
76
+
77
+ @self.settings_app.post("/tick")
78
+ def request_tick():
79
+ nonlocal play_tick
80
+ play_tick = True
81
+ return {"status": "ok"}
82
+
83
+ # Main control loop at 50Hz
84
+ last_beat = -1
85
+
86
+ while not stop_event.is_set():
87
+ if is_running:
88
+ current_time = time.time()
89
+ elapsed = current_time - beat_start_time
90
+
91
+ # Calculate beat timing
92
+ beat_duration = 60.0 / bpm # seconds per beat
93
+ beat_progress = (elapsed % beat_duration) / beat_duration # 0 to 1
94
+ current_beat = int(elapsed / beat_duration) % 4 # 0, 1, 2, 3 for 4/4 time
95
+
96
+ # Detect new beat for sound
97
+ if current_beat != last_beat:
98
+ last_beat = current_beat
99
+ # Play tick sound on each beat
100
+ try:
101
+ reachy_mini.media.play_sound(CLICK_SOUND)
102
+ except Exception:
103
+ pass # Sound file might not exist
104
+
105
+ # Pendulum motion: smooth sine wave oscillation
106
+ # Full cycle = 2 beats (swing left, swing right)
107
+ cycle_progress = (elapsed % (beat_duration * 2)) / (beat_duration * 2)
108
+ angle = amplitude_deg * math.sin(2 * math.pi * cycle_progress)
109
+
110
+ # Both antennas move together like a metronome pendulum
111
+ antennas_deg = np.array([angle, angle])
112
+
113
+ # Head follows slightly
114
+ head_yaw = angle * 0.3
115
+ head_pose = create_head_pose(yaw=head_yaw, degrees=True)
116
+ else:
117
+ # Stopped: antennas at rest
118
+ antennas_deg = np.array([0.0, 0.0])
119
+ head_pose = create_head_pose(yaw=0, degrees=True)
120
+ last_beat = -1
121
+
122
+ # Handle manual tick request
123
+ if play_tick:
124
+ try:
125
+ reachy_mini.media.play_sound(CLICK_SOUND)
126
+ except Exception:
127
+ pass
128
+ play_tick = False
129
+
130
+ antennas_rad = np.deg2rad(antennas_deg)
131
+
132
+ reachy_mini.set_target(
133
+ head=head_pose,
134
+ antennas=antennas_rad,
135
+ )
136
+
137
+ time.sleep(0.02) # 50Hz update rate
138
+
139
+
140
+ if __name__ == "__main__":
141
+ app = Metronome()
142
+ app.wrapped_run(media_backend="default_no_video")
metronome/static/click.wav ADDED
Binary file (4.45 kB). View file
 
metronome/static/index.html ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>Reachy Mini Metronome</title>
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
11
+ <link rel="stylesheet" href="/static/style.css">
12
+ </head>
13
+
14
+ <body>
15
+ <div class="container">
16
+ <header>
17
+ <div class="logo">
18
+ <div class="logo-icon"></div>
19
+ <span>REACHY MINI</span>
20
+ </div>
21
+ <h1>METRONOME</h1>
22
+ </header>
23
+
24
+ <main>
25
+ <!-- Pendulum visualization -->
26
+ <div class="pendulum-container">
27
+ <div class="pendulum-track"></div>
28
+ <div class="pendulum-arm" id="pendulum">
29
+ <div class="pendulum-weight"></div>
30
+ </div>
31
+ <div class="beat-indicators">
32
+ <div class="beat-dot" data-beat="0"></div>
33
+ <div class="beat-dot" data-beat="1"></div>
34
+ <div class="beat-dot" data-beat="2"></div>
35
+ <div class="beat-dot" data-beat="3"></div>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- BPM Display -->
40
+ <div class="bpm-display">
41
+ <button class="bpm-adjust" id="bpm-down">-</button>
42
+ <div class="bpm-value">
43
+ <input type="number" id="bpm-input" value="120" min="40" max="240">
44
+ <span class="bpm-label">BPM</span>
45
+ </div>
46
+ <button class="bpm-adjust" id="bpm-up">+</button>
47
+ </div>
48
+
49
+ <!-- BPM Slider -->
50
+ <div class="slider-container">
51
+ <span class="slider-label">40</span>
52
+ <input type="range" id="bpm-slider" min="40" max="240" value="120">
53
+ <span class="slider-label">240</span>
54
+ </div>
55
+
56
+ <!-- Tempo markings -->
57
+ <div class="tempo-marks">
58
+ <button class="tempo-preset" data-bpm="60">Largo</button>
59
+ <button class="tempo-preset" data-bpm="90">Andante</button>
60
+ <button class="tempo-preset" data-bpm="120">Allegro</button>
61
+ <button class="tempo-preset" data-bpm="160">Vivace</button>
62
+ <button class="tempo-preset" data-bpm="200">Presto</button>
63
+ </div>
64
+
65
+ <!-- Controls -->
66
+ <div class="controls">
67
+ <button class="control-btn secondary" id="tap-btn">
68
+ TAP TEMPO
69
+ </button>
70
+ <button class="control-btn primary" id="play-btn">
71
+ <span class="play-icon"></span>
72
+ START
73
+ </button>
74
+ </div>
75
+
76
+ <!-- Status -->
77
+ <div class="status" id="status">
78
+ <div class="status-indicator"></div>
79
+ <span>Ready</span>
80
+ </div>
81
+ </main>
82
+
83
+ <footer>
84
+ <p>Antenna amplitude: <span id="amplitude-value">35</span>&deg;</p>
85
+ </footer>
86
+ </div>
87
+
88
+ <script src="/static/main.js"></script>
89
+ </body>
90
+
91
+ </html>
metronome/static/main.js ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Metronome state
2
+ let state = {
3
+ bpm: 120,
4
+ isRunning: false,
5
+ amplitude: 35,
6
+ currentBeat: 0
7
+ };
8
+
9
+ // Audio context for click sounds
10
+ let audioCtx = null;
11
+
12
+ function initAudio() {
13
+ if (!audioCtx) {
14
+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
15
+ }
16
+ }
17
+
18
+ function playClick(isDownbeat = false) {
19
+ if (!audioCtx) return;
20
+
21
+ const osc = audioCtx.createOscillator();
22
+ const gain = audioCtx.createGain();
23
+
24
+ osc.connect(gain);
25
+ gain.connect(audioCtx.destination);
26
+
27
+ // Higher pitch for downbeat (beat 1)
28
+ osc.frequency.value = isDownbeat ? 1000 : 800;
29
+ osc.type = 'sine';
30
+
31
+ gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
32
+ gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1);
33
+
34
+ osc.start(audioCtx.currentTime);
35
+ osc.stop(audioCtx.currentTime + 0.1);
36
+ }
37
+
38
+ // Tap tempo tracking
39
+ let tapTimes = [];
40
+ const TAP_TIMEOUT = 2000; // Reset after 2 seconds of no taps
41
+
42
+ // DOM Elements
43
+ const pendulum = document.getElementById('pendulum');
44
+ const bpmInput = document.getElementById('bpm-input');
45
+ const bpmSlider = document.getElementById('bpm-slider');
46
+ const bpmUp = document.getElementById('bpm-up');
47
+ const bpmDown = document.getElementById('bpm-down');
48
+ const playBtn = document.getElementById('play-btn');
49
+ const tapBtn = document.getElementById('tap-btn');
50
+ const statusEl = document.getElementById('status');
51
+ const amplitudeValue = document.getElementById('amplitude-value');
52
+ const beatDots = document.querySelectorAll('.beat-dot');
53
+ const tempoPresets = document.querySelectorAll('.tempo-preset');
54
+
55
+ // Animation
56
+ let animationStartTime = null;
57
+ let animationFrame = null;
58
+
59
+ // API Functions
60
+ async function fetchState() {
61
+ try {
62
+ const resp = await fetch('/state');
63
+ const data = await resp.json();
64
+ state.bpm = data.bpm;
65
+ state.isRunning = data.is_running;
66
+ state.amplitude = data.amplitude;
67
+ state.currentBeat = data.current_beat;
68
+ updateUI();
69
+ } catch (e) {
70
+ console.error('Failed to fetch state:', e);
71
+ }
72
+ }
73
+
74
+ async function updateState(updates) {
75
+ try {
76
+ const resp = await fetch('/state', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify(updates)
80
+ });
81
+ const data = await resp.json();
82
+ state.bpm = data.bpm;
83
+ state.isRunning = data.is_running;
84
+ state.amplitude = data.amplitude;
85
+ state.currentBeat = data.current_beat;
86
+ updateUI();
87
+ } catch (e) {
88
+ console.error('Failed to update state:', e);
89
+ statusEl.querySelector('span').textContent = 'Connection error';
90
+ }
91
+ }
92
+
93
+ // UI Update
94
+ function updateUI() {
95
+ // BPM displays
96
+ bpmInput.value = state.bpm;
97
+ bpmSlider.value = state.bpm;
98
+ amplitudeValue.textContent = Math.round(state.amplitude);
99
+
100
+ // Play button
101
+ if (state.isRunning) {
102
+ playBtn.innerHTML = '<span class="stop-icon"></span>STOP';
103
+ playBtn.classList.add('running');
104
+ statusEl.querySelector('span').textContent = 'Running';
105
+ statusEl.classList.add('active');
106
+ startPendulumAnimation();
107
+ } else {
108
+ playBtn.innerHTML = '<span class="play-icon"></span>START';
109
+ playBtn.classList.remove('running');
110
+ statusEl.querySelector('span').textContent = 'Ready';
111
+ statusEl.classList.remove('active');
112
+ stopPendulumAnimation();
113
+ }
114
+
115
+ // Beat indicators
116
+ beatDots.forEach((dot, i) => {
117
+ if (i === state.currentBeat && state.isRunning) {
118
+ dot.classList.add('active');
119
+ } else {
120
+ dot.classList.remove('active');
121
+ }
122
+ });
123
+
124
+ // Tempo presets
125
+ tempoPresets.forEach(preset => {
126
+ const presetBpm = parseInt(preset.dataset.bpm);
127
+ if (Math.abs(state.bpm - presetBpm) < 10) {
128
+ preset.classList.add('active');
129
+ } else {
130
+ preset.classList.remove('active');
131
+ }
132
+ });
133
+ }
134
+
135
+ // Pendulum Animation
136
+ function startPendulumAnimation() {
137
+ if (animationFrame) return;
138
+ animationStartTime = performance.now();
139
+ animatePendulum();
140
+ }
141
+
142
+ function stopPendulumAnimation() {
143
+ if (animationFrame) {
144
+ cancelAnimationFrame(animationFrame);
145
+ animationFrame = null;
146
+ }
147
+ pendulum.style.transform = 'translateX(-50%) rotate(0deg)';
148
+ }
149
+
150
+ function animatePendulum() {
151
+ if (!state.isRunning) {
152
+ stopPendulumAnimation();
153
+ return;
154
+ }
155
+
156
+ const now = performance.now();
157
+ const elapsed = (now - animationStartTime) / 1000; // seconds
158
+
159
+ const beatDuration = 60 / state.bpm;
160
+ const cycleProgress = (elapsed % (beatDuration * 2)) / (beatDuration * 2);
161
+ const angle = 35 * Math.sin(2 * Math.PI * cycleProgress);
162
+
163
+ pendulum.style.transform = `translateX(-50%) rotate(${angle}deg)`;
164
+
165
+ // Update beat indicator
166
+ const currentBeat = Math.floor((elapsed / beatDuration) % 4);
167
+ if (currentBeat !== state.currentBeat) {
168
+ state.currentBeat = currentBeat;
169
+ updateBeatIndicators();
170
+ }
171
+
172
+ animationFrame = requestAnimationFrame(animatePendulum);
173
+ }
174
+
175
+ function updateBeatIndicators() {
176
+ beatDots.forEach((dot, i) => {
177
+ if (i === state.currentBeat) {
178
+ dot.classList.add('active');
179
+ } else {
180
+ dot.classList.remove('active');
181
+ }
182
+ });
183
+
184
+ // Play click sound on beat change
185
+ playClick(state.currentBeat === 0);
186
+ }
187
+
188
+ // Tap Tempo
189
+ function handleTap() {
190
+ const now = Date.now();
191
+
192
+ // Reset if too long since last tap
193
+ if (tapTimes.length > 0 && now - tapTimes[tapTimes.length - 1] > TAP_TIMEOUT) {
194
+ tapTimes = [];
195
+ }
196
+
197
+ tapTimes.push(now);
198
+
199
+ // Need at least 2 taps to calculate BPM
200
+ if (tapTimes.length >= 2) {
201
+ // Keep only last 8 taps
202
+ if (tapTimes.length > 8) {
203
+ tapTimes = tapTimes.slice(-8);
204
+ }
205
+
206
+ // Calculate average interval
207
+ let totalInterval = 0;
208
+ for (let i = 1; i < tapTimes.length; i++) {
209
+ totalInterval += tapTimes[i] - tapTimes[i - 1];
210
+ }
211
+ const avgInterval = totalInterval / (tapTimes.length - 1);
212
+ const newBpm = Math.round(60000 / avgInterval);
213
+
214
+ // Clamp to valid range
215
+ const clampedBpm = Math.max(40, Math.min(240, newBpm));
216
+ updateState({ bpm: clampedBpm });
217
+ }
218
+
219
+ // Visual feedback
220
+ tapBtn.classList.add('tapped');
221
+ setTimeout(() => tapBtn.classList.remove('tapped'), 100);
222
+ }
223
+
224
+ // Event Listeners
225
+ playBtn.addEventListener('click', () => {
226
+ initAudio(); // Initialize audio on user gesture
227
+ updateState({ is_running: !state.isRunning });
228
+ });
229
+
230
+ tapBtn.addEventListener('click', () => {
231
+ initAudio();
232
+ playClick(true);
233
+ handleTap();
234
+ });
235
+
236
+ bpmInput.addEventListener('change', (e) => {
237
+ const newBpm = parseInt(e.target.value) || 120;
238
+ updateState({ bpm: newBpm });
239
+ });
240
+
241
+ bpmSlider.addEventListener('input', (e) => {
242
+ const newBpm = parseInt(e.target.value);
243
+ bpmInput.value = newBpm;
244
+ });
245
+
246
+ bpmSlider.addEventListener('change', (e) => {
247
+ const newBpm = parseInt(e.target.value);
248
+ updateState({ bpm: newBpm });
249
+ });
250
+
251
+ bpmUp.addEventListener('click', () => {
252
+ updateState({ bpm: state.bpm + 1 });
253
+ });
254
+
255
+ bpmDown.addEventListener('click', () => {
256
+ updateState({ bpm: state.bpm - 1 });
257
+ });
258
+
259
+ tempoPresets.forEach(preset => {
260
+ preset.addEventListener('click', () => {
261
+ const newBpm = parseInt(preset.dataset.bpm);
262
+ updateState({ bpm: newBpm });
263
+ });
264
+ });
265
+
266
+ // Keyboard shortcuts
267
+ document.addEventListener('keydown', (e) => {
268
+ if (e.target.tagName === 'INPUT') return;
269
+
270
+ switch (e.code) {
271
+ case 'Space':
272
+ e.preventDefault();
273
+ updateState({ is_running: !state.isRunning });
274
+ break;
275
+ case 'KeyT':
276
+ handleTap();
277
+ break;
278
+ case 'ArrowUp':
279
+ updateState({ bpm: state.bpm + 5 });
280
+ break;
281
+ case 'ArrowDown':
282
+ updateState({ bpm: state.bpm - 5 });
283
+ break;
284
+ case 'ArrowRight':
285
+ updateState({ bpm: state.bpm + 1 });
286
+ break;
287
+ case 'ArrowLeft':
288
+ updateState({ bpm: state.bpm - 1 });
289
+ break;
290
+ }
291
+ });
292
+
293
+ // Poll for state updates when running (to stay in sync with robot)
294
+ setInterval(() => {
295
+ if (state.isRunning) {
296
+ fetchState();
297
+ }
298
+ }, 500);
299
+
300
+ // Initialize
301
+ fetchState();
metronome/static/style.css ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #ffffff;
3
+ --bg-secondary: #f5f5f5;
4
+ --text: #111111;
5
+ --text-muted: #888888;
6
+ --border: #e0e0e0;
7
+ --accent: #111111;
8
+ }
9
+
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
+ background: var(--bg);
19
+ color: var(--text);
20
+ min-height: 100vh;
21
+ }
22
+
23
+ .container {
24
+ max-width: 280px;
25
+ margin: 0 auto;
26
+ padding: 24px 16px;
27
+ min-height: 100vh;
28
+ display: flex;
29
+ flex-direction: column;
30
+ }
31
+
32
+ header {
33
+ text-align: center;
34
+ margin-bottom: 24px;
35
+ }
36
+
37
+ .logo {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ gap: 6px;
42
+ margin-bottom: 4px;
43
+ }
44
+
45
+ .logo-icon {
46
+ width: 4px;
47
+ height: 4px;
48
+ background: var(--text);
49
+ border-radius: 50%;
50
+ }
51
+
52
+ .logo span {
53
+ font-size: 9px;
54
+ letter-spacing: 2px;
55
+ color: var(--text-muted);
56
+ text-transform: uppercase;
57
+ }
58
+
59
+ h1 {
60
+ font-size: 14px;
61
+ font-weight: 500;
62
+ letter-spacing: 4px;
63
+ text-transform: uppercase;
64
+ }
65
+
66
+ main {
67
+ flex: 1;
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 16px;
71
+ }
72
+
73
+ /* Pendulum */
74
+ .pendulum-container {
75
+ position: relative;
76
+ height: 80px;
77
+ display: flex;
78
+ align-items: flex-start;
79
+ justify-content: center;
80
+ padding-top: 8px;
81
+ }
82
+
83
+ .pendulum-track {
84
+ display: none;
85
+ }
86
+
87
+ .pendulum-arm {
88
+ position: absolute;
89
+ top: 8px;
90
+ left: 50%;
91
+ width: 1px;
92
+ height: 50px;
93
+ background: var(--text);
94
+ transform-origin: top center;
95
+ transform: translateX(-50%) rotate(0deg);
96
+ transition: transform 0.016s linear;
97
+ }
98
+
99
+ .pendulum-weight {
100
+ position: absolute;
101
+ bottom: -4px;
102
+ left: 50%;
103
+ transform: translateX(-50%);
104
+ width: 8px;
105
+ height: 8px;
106
+ background: var(--text);
107
+ border-radius: 50%;
108
+ }
109
+
110
+ .beat-indicators {
111
+ position: absolute;
112
+ bottom: 8px;
113
+ left: 50%;
114
+ transform: translateX(-50%);
115
+ display: flex;
116
+ gap: 6px;
117
+ }
118
+
119
+ .beat-dot {
120
+ width: 6px;
121
+ height: 6px;
122
+ background: var(--bg);
123
+ border: 1px solid var(--border);
124
+ border-radius: 50%;
125
+ transition: all 0.1s ease;
126
+ }
127
+
128
+ .beat-dot.active {
129
+ background: var(--text);
130
+ border-color: var(--text);
131
+ }
132
+
133
+ /* BPM Display */
134
+ .bpm-display {
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ gap: 16px;
139
+ }
140
+
141
+ .bpm-adjust {
142
+ width: 28px;
143
+ height: 28px;
144
+ background: var(--bg);
145
+ border: 1px solid var(--border);
146
+ border-radius: 4px;
147
+ color: var(--text);
148
+ font-size: 14px;
149
+ cursor: pointer;
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ }
154
+
155
+ .bpm-adjust:hover {
156
+ background: var(--bg-secondary);
157
+ }
158
+
159
+ .bpm-adjust:active {
160
+ background: var(--text);
161
+ color: var(--bg);
162
+ }
163
+
164
+ .bpm-value {
165
+ display: flex;
166
+ flex-direction: column;
167
+ align-items: center;
168
+ }
169
+
170
+ .bpm-value input {
171
+ width: 64px;
172
+ background: transparent;
173
+ border: none;
174
+ font-family: inherit;
175
+ font-size: 32px;
176
+ font-weight: 300;
177
+ color: var(--text);
178
+ text-align: center;
179
+ appearance: textfield;
180
+ -moz-appearance: textfield;
181
+ }
182
+
183
+ .bpm-value input::-webkit-outer-spin-button,
184
+ .bpm-value input::-webkit-inner-spin-button {
185
+ -webkit-appearance: none;
186
+ margin: 0;
187
+ }
188
+
189
+ .bpm-value input:focus {
190
+ outline: none;
191
+ }
192
+
193
+ .bpm-label {
194
+ font-size: 9px;
195
+ letter-spacing: 2px;
196
+ color: var(--text-muted);
197
+ text-transform: uppercase;
198
+ }
199
+
200
+ /* Slider */
201
+ .slider-container {
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 8px;
205
+ }
206
+
207
+ .slider-label {
208
+ font-size: 9px;
209
+ color: var(--text-muted);
210
+ min-width: 20px;
211
+ }
212
+
213
+ input[type="range"] {
214
+ flex: 1;
215
+ height: 1px;
216
+ background: var(--border);
217
+ outline: none;
218
+ -webkit-appearance: none;
219
+ border: none;
220
+ }
221
+
222
+ input[type="range"]::-webkit-slider-thumb {
223
+ -webkit-appearance: none;
224
+ width: 12px;
225
+ height: 12px;
226
+ background: var(--text);
227
+ border-radius: 50%;
228
+ cursor: pointer;
229
+ }
230
+
231
+ input[type="range"]::-moz-range-thumb {
232
+ width: 12px;
233
+ height: 12px;
234
+ background: var(--text);
235
+ border-radius: 50%;
236
+ cursor: pointer;
237
+ border: none;
238
+ }
239
+
240
+ /* Tempo Presets */
241
+ .tempo-marks {
242
+ display: flex;
243
+ justify-content: center;
244
+ gap: 4px;
245
+ }
246
+
247
+ .tempo-preset {
248
+ padding: 4px 8px;
249
+ background: var(--bg);
250
+ border: 1px solid var(--border);
251
+ border-radius: 3px;
252
+ color: var(--text-muted);
253
+ font-family: inherit;
254
+ font-size: 9px;
255
+ cursor: pointer;
256
+ }
257
+
258
+ .tempo-preset:hover {
259
+ color: var(--text);
260
+ }
261
+
262
+ .tempo-preset.active {
263
+ background: var(--text);
264
+ border-color: var(--text);
265
+ color: var(--bg);
266
+ }
267
+
268
+ /* Controls */
269
+ .controls {
270
+ display: flex;
271
+ gap: 8px;
272
+ margin-top: 8px;
273
+ }
274
+
275
+ .control-btn {
276
+ flex: 1;
277
+ padding: 12px;
278
+ border: 1px solid var(--border);
279
+ border-radius: 4px;
280
+ font-family: inherit;
281
+ font-size: 11px;
282
+ letter-spacing: 1px;
283
+ text-transform: uppercase;
284
+ cursor: pointer;
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: center;
288
+ gap: 6px;
289
+ background: var(--bg);
290
+ color: var(--text);
291
+ }
292
+
293
+ .control-btn.primary {
294
+ background: var(--text);
295
+ border-color: var(--text);
296
+ color: var(--bg);
297
+ }
298
+
299
+ .control-btn.primary:hover {
300
+ opacity: 0.9;
301
+ }
302
+
303
+ .control-btn.primary.running {
304
+ background: var(--bg);
305
+ color: var(--text);
306
+ }
307
+
308
+ .control-btn.secondary:hover {
309
+ background: var(--bg-secondary);
310
+ }
311
+
312
+ .control-btn.secondary.tapped {
313
+ background: var(--text);
314
+ color: var(--bg);
315
+ }
316
+
317
+ /* Play/Stop Icons */
318
+ .play-icon {
319
+ width: 0;
320
+ height: 0;
321
+ border-left: 6px solid var(--bg);
322
+ border-top: 4px solid transparent;
323
+ border-bottom: 4px solid transparent;
324
+ }
325
+
326
+ .control-btn.primary.running .play-icon {
327
+ border-left-color: var(--text);
328
+ }
329
+
330
+ .stop-icon {
331
+ width: 8px;
332
+ height: 8px;
333
+ background: var(--bg);
334
+ }
335
+
336
+ .control-btn.primary.running .stop-icon {
337
+ background: var(--text);
338
+ }
339
+
340
+ /* Status */
341
+ .status {
342
+ display: flex;
343
+ align-items: center;
344
+ justify-content: center;
345
+ gap: 6px;
346
+ padding: 8px;
347
+ }
348
+
349
+ .status-indicator {
350
+ width: 4px;
351
+ height: 4px;
352
+ background: var(--border);
353
+ border-radius: 50%;
354
+ }
355
+
356
+ .status.active .status-indicator {
357
+ background: var(--text);
358
+ }
359
+
360
+ .status span {
361
+ font-size: 9px;
362
+ letter-spacing: 1px;
363
+ color: var(--text-muted);
364
+ text-transform: uppercase;
365
+ }
366
+
367
+ .status.active span {
368
+ color: var(--text);
369
+ }
370
+
371
+ /* Footer */
372
+ footer {
373
+ margin-top: auto;
374
+ padding-top: 16px;
375
+ text-align: center;
376
+ }
377
+
378
+ footer p {
379
+ font-size: 9px;
380
+ color: var(--text-muted);
381
+ }
382
+
383
+ /* Responsive */
384
+ @media (max-width: 480px) {
385
+ .container {
386
+ padding: 16px 12px;
387
+ }
388
+
389
+ .bpm-value input {
390
+ font-size: 28px;
391
+ }
392
+ }
pyproject.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+
6
+ [project]
7
+ name = "metronome"
8
+ version = "0.1.0"
9
+ description = "Metronome app for Reachy Mini"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "reachy-mini"
14
+ ]
15
+ keywords = ["reachy-mini-app"]
16
+
17
+ [project.entry-points."reachy_mini_apps"]
18
+ metronome = "metronome.main:Metronome"
19
+
20
+ [tool.setuptools]
21
+ package-dir = { "" = "." }
22
+ include-package-data = true
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["."]
26
+
27
+ [tool.setuptools.package-data]
28
+ metronome = ["**/*"] # Also include all non-.py files
style.css ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
9
+ line-height: 1.6;
10
+ color: #333;
11
+ background: #fff;
12
+ min-height: 100vh;
13
+ }
14
+
15
+ .hero {
16
+ background: #f5f5f5;
17
+ color: #333;
18
+ padding: 2rem 1rem;
19
+ text-align: center;
20
+ }
21
+
22
+ .hero-content {
23
+ max-width: 600px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ .app-icon {
28
+ font-size: 2rem;
29
+ margin-bottom: 0.5rem;
30
+ display: inline-block;
31
+ }
32
+
33
+ .hero h1 {
34
+ font-size: 1.5rem;
35
+ font-weight: 600;
36
+ margin-bottom: 0.5rem;
37
+ }
38
+
39
+ .tagline {
40
+ font-size: 1rem;
41
+ color: #666;
42
+ }
43
+
44
+ .container {
45
+ max-width: 600px;
46
+ margin: 0 auto;
47
+ padding: 1rem;
48
+ }
49
+
50
+ .main-card {
51
+ background: #fff;
52
+ border: 1px solid #eee;
53
+ margin-bottom: 1rem;
54
+ }
55
+
56
+ .app-preview {
57
+ background: #f5f5f5;
58
+ padding: 1rem;
59
+ text-align: center;
60
+ }
61
+
62
+ .preview-image {
63
+ background: #eee;
64
+ padding: 1rem;
65
+ max-width: 300px;
66
+ margin: 0 auto;
67
+ }
68
+
69
+ .camera-feed {
70
+ font-size: 2rem;
71
+ opacity: 0.5;
72
+ }
73
+
74
+ .detection-overlay {
75
+ display: none;
76
+ }
77
+
78
+ .bbox {
79
+ background: #333;
80
+ color: white;
81
+ padding: 0.25rem 0.5rem;
82
+ font-size: 0.8rem;
83
+ display: inline-block;
84
+ }
85
+
86
+ .app-details {
87
+ padding: 1rem;
88
+ }
89
+
90
+ .app-details h2 {
91
+ font-size: 1.25rem;
92
+ margin-bottom: 1rem;
93
+ }
94
+
95
+ .template-info {
96
+ display: block;
97
+ }
98
+
99
+ .info-box {
100
+ background: #f9f9f9;
101
+ border: 1px solid #eee;
102
+ padding: 1rem;
103
+ margin-bottom: 1rem;
104
+ }
105
+
106
+ .info-box h3 {
107
+ font-size: 1rem;
108
+ margin-bottom: 0.5rem;
109
+ }
110
+
111
+ .info-box p {
112
+ color: #666;
113
+ }
114
+
115
+ .how-to-use {
116
+ background: #f9f9f9;
117
+ border: 1px solid #eee;
118
+ padding: 1rem;
119
+ margin-top: 1rem;
120
+ }
121
+
122
+ .how-to-use h3 {
123
+ font-size: 1rem;
124
+ margin-bottom: 1rem;
125
+ }
126
+
127
+ .steps {
128
+ display: block;
129
+ }
130
+
131
+ .step {
132
+ display: flex;
133
+ align-items: flex-start;
134
+ gap: 0.5rem;
135
+ margin-bottom: 0.5rem;
136
+ }
137
+
138
+ .step-number {
139
+ background: #333;
140
+ color: white;
141
+ width: 1.5rem;
142
+ height: 1.5rem;
143
+ border-radius: 50%;
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ font-size: 0.75rem;
148
+ flex-shrink: 0;
149
+ }
150
+
151
+ .step h4 {
152
+ font-size: 0.9rem;
153
+ margin-bottom: 0.25rem;
154
+ }
155
+
156
+ .step p {
157
+ color: #666;
158
+ font-size: 0.85rem;
159
+ }
160
+
161
+ .download-section {
162
+ padding: 1rem;
163
+ }
164
+
165
+ .download-card {
166
+ background: #fff;
167
+ border: 1px solid #eee;
168
+ padding: 1.5rem;
169
+ text-align: center;
170
+ max-width: 600px;
171
+ margin: 0 auto;
172
+ }
173
+
174
+ .download-card h2 {
175
+ font-size: 1.25rem;
176
+ margin-bottom: 1rem;
177
+ }
178
+
179
+ .download-card>p {
180
+ color: #666;
181
+ margin-bottom: 1rem;
182
+ }
183
+
184
+ .dashboard-config {
185
+ margin-bottom: 1rem;
186
+ text-align: left;
187
+ }
188
+
189
+ .dashboard-config label {
190
+ display: block;
191
+ font-size: 0.9rem;
192
+ margin-bottom: 0.25rem;
193
+ }
194
+
195
+ .dashboard-config input {
196
+ width: 100%;
197
+ padding: 0.5rem;
198
+ border: 1px solid #ddd;
199
+ font-size: 0.9rem;
200
+ }
201
+
202
+ .dashboard-config input:focus {
203
+ outline: none;
204
+ border-color: #333;
205
+ }
206
+
207
+ .install-btn {
208
+ background: #333;
209
+ color: white;
210
+ border: none;
211
+ padding: 0.75rem 1.5rem;
212
+ font-size: 1rem;
213
+ cursor: pointer;
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 0.5rem;
217
+ margin-bottom: 1rem;
218
+ }
219
+
220
+ .install-btn:hover:not(:disabled) {
221
+ background: #555;
222
+ }
223
+
224
+ .install-btn:disabled {
225
+ opacity: 0.5;
226
+ cursor: not-allowed;
227
+ }
228
+
229
+ .manual-option {
230
+ background: #f9f9f9;
231
+ padding: 1rem;
232
+ margin-top: 1rem;
233
+ }
234
+
235
+ .manual-option h3 {
236
+ font-size: 1rem;
237
+ margin-bottom: 0.5rem;
238
+ }
239
+
240
+ .manual-option>p {
241
+ color: #666;
242
+ margin-bottom: 0.5rem;
243
+ }
244
+
245
+ .btn-icon {
246
+ font-size: 1rem;
247
+ }
248
+
249
+ .install-status {
250
+ padding: 0.75rem;
251
+ font-size: 0.85rem;
252
+ text-align: center;
253
+ display: none;
254
+ margin-top: 0.5rem;
255
+ }
256
+
257
+ .install-status.success {
258
+ background: #e8f5e9;
259
+ color: #2e7d32;
260
+ }
261
+
262
+ .install-status.error {
263
+ background: #ffebee;
264
+ color: #c62828;
265
+ }
266
+
267
+ .install-status.loading {
268
+ background: #e3f2fd;
269
+ color: #1565c0;
270
+ }
271
+
272
+ .install-status.info {
273
+ background: #e3f2fd;
274
+ color: #1565c0;
275
+ }
276
+
277
+ .manual-install {
278
+ background: #333;
279
+ padding: 0.75rem;
280
+ margin-bottom: 0.5rem;
281
+ display: flex;
282
+ align-items: center;
283
+ gap: 0.5rem;
284
+ }
285
+
286
+ .manual-install code {
287
+ color: #4caf50;
288
+ font-family: monospace;
289
+ font-size: 0.8rem;
290
+ flex: 1;
291
+ overflow-x: auto;
292
+ }
293
+
294
+ .copy-btn {
295
+ background: #555;
296
+ color: white;
297
+ border: none;
298
+ padding: 0.25rem 0.5rem;
299
+ font-size: 0.75rem;
300
+ cursor: pointer;
301
+ }
302
+
303
+ .copy-btn:hover {
304
+ background: #777;
305
+ }
306
+
307
+ .manual-steps {
308
+ color: #666;
309
+ font-size: 0.85rem;
310
+ }
311
+
312
+ .footer {
313
+ text-align: center;
314
+ padding: 1rem;
315
+ color: #666;
316
+ }
317
+
318
+ .footer a {
319
+ color: #333;
320
+ }
321
+
322
+ .footer a:hover {
323
+ text-decoration: underline;
324
+ }
325
+
326
+ @media (max-width: 768px) {
327
+ .hero {
328
+ padding: 1rem;
329
+ }
330
+
331
+ .hero h1 {
332
+ font-size: 1.25rem;
333
+ }
334
+
335
+ .container {
336
+ padding: 0.5rem;
337
+ }
338
+
339
+ .app-details,
340
+ .download-card {
341
+ padding: 1rem;
342
+ }
343
+
344
+ .features-grid {
345
+ grid-template-columns: 1fr;
346
+ }
347
+
348
+ .download-options {
349
+ grid-template-columns: 1fr;
350
+ }
351
+ }