openvoiceui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- package/tts_providers/supertonic_tts.py +399 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FaceRenderer - Modular face/avatar rendering system
|
|
3
|
+
* Supports multiple face modes: eyes, orb, future options
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
window.FaceRenderer = {
|
|
7
|
+
// Available face modes
|
|
8
|
+
modes: {
|
|
9
|
+
'eyes': {
|
|
10
|
+
name: 'AI Eyes',
|
|
11
|
+
description: 'Classic animated eyes'
|
|
12
|
+
},
|
|
13
|
+
'halo-smoke': {
|
|
14
|
+
name: 'Halo Smoke Orb',
|
|
15
|
+
description: 'Halo ring + wispy smoke core, reacts to TTS audio'
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
currentMode: 'eyes',
|
|
20
|
+
container: null,
|
|
21
|
+
audioContext: null,
|
|
22
|
+
analyser: null,
|
|
23
|
+
animationFrame: null,
|
|
24
|
+
|
|
25
|
+
// Orb-specific state
|
|
26
|
+
orb: {
|
|
27
|
+
canvas: null,
|
|
28
|
+
ctx: null,
|
|
29
|
+
particles: [],
|
|
30
|
+
baseRadius: 80,
|
|
31
|
+
pulsePhase: 0
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
init() {
|
|
35
|
+
this.container = document.querySelector('.face-box');
|
|
36
|
+
if (!this.container) {
|
|
37
|
+
console.warn('FaceRenderer: .face-box container not found');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Load saved mode from server profile (shared across devices)
|
|
42
|
+
const savedMode = window._serverProfile?.ui?.face_mode;
|
|
43
|
+
if (savedMode && this.modes[savedMode]) {
|
|
44
|
+
this.currentMode = savedMode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Listen for theme changes
|
|
48
|
+
window.addEventListener('themeChanged', (e) => {
|
|
49
|
+
if (this.currentMode === 'orb') {
|
|
50
|
+
this.updateOrbColors(e.detail);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Initial render
|
|
55
|
+
this.render();
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
setMode(modeName) {
|
|
59
|
+
if (!this.modes[modeName]) {
|
|
60
|
+
console.warn('Unknown face mode:', modeName);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Clean up current mode
|
|
65
|
+
this.cleanup();
|
|
66
|
+
|
|
67
|
+
this.currentMode = modeName;
|
|
68
|
+
// Persist to server profile
|
|
69
|
+
const profileId = window.providerManager?._activeProfileId || 'default';
|
|
70
|
+
fetch('/api/profiles/' + profileId, {
|
|
71
|
+
method: 'PUT',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ ui: { face_mode: modeName } })
|
|
74
|
+
}).catch(e => console.warn('Failed to save face mode:', e));
|
|
75
|
+
if (window._serverProfile) {
|
|
76
|
+
if (!window._serverProfile.ui) window._serverProfile.ui = {};
|
|
77
|
+
window._serverProfile.ui.face_mode = modeName;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Re-render
|
|
81
|
+
this.render();
|
|
82
|
+
|
|
83
|
+
// Dispatch event
|
|
84
|
+
window.dispatchEvent(new CustomEvent('faceModeChanged', { detail: modeName }));
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
cleanup() {
|
|
88
|
+
if (this.animationFrame) {
|
|
89
|
+
cancelAnimationFrame(this.animationFrame);
|
|
90
|
+
this.animationFrame = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Remove orb canvas if exists
|
|
94
|
+
if (this.orb.canvas && this.orb.canvas.parentNode) {
|
|
95
|
+
this.orb.canvas.parentNode.removeChild(this.orb.canvas);
|
|
96
|
+
this.orb.canvas = null;
|
|
97
|
+
this.orb.ctx = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stop halo-smoke face if running
|
|
101
|
+
if (window.HaloSmokeFace) {
|
|
102
|
+
window.HaloSmokeFace.stop();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
render() {
|
|
107
|
+
switch (this.currentMode) {
|
|
108
|
+
case 'eyes':
|
|
109
|
+
this.renderEyes();
|
|
110
|
+
break;
|
|
111
|
+
case 'orb':
|
|
112
|
+
this.renderOrb();
|
|
113
|
+
break;
|
|
114
|
+
case 'halo-smoke':
|
|
115
|
+
this.renderHaloSmoke();
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
renderEyes() {
|
|
121
|
+
// Show existing eyes, hide orb
|
|
122
|
+
const eyesContainer = this.container.querySelector('.eyes-container');
|
|
123
|
+
if (eyesContainer) {
|
|
124
|
+
eyesContainer.style.display = 'flex';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Remove orb canvas if present
|
|
128
|
+
this.cleanup();
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
renderOrb() {
|
|
132
|
+
// Hide existing eyes
|
|
133
|
+
const eyesContainer = this.container.querySelector('.eyes-container');
|
|
134
|
+
if (eyesContainer) {
|
|
135
|
+
eyesContainer.style.display = 'none';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Create orb canvas
|
|
139
|
+
if (!this.orb.canvas) {
|
|
140
|
+
this.orb.canvas = document.createElement('canvas');
|
|
141
|
+
this.orb.canvas.id = 'orb-canvas';
|
|
142
|
+
this.orb.canvas.style.cssText = `
|
|
143
|
+
position: absolute;
|
|
144
|
+
top: 50%;
|
|
145
|
+
left: 50%;
|
|
146
|
+
transform: translate(-50%, -50%);
|
|
147
|
+
pointer-events: none;
|
|
148
|
+
`;
|
|
149
|
+
this.container.appendChild(this.orb.canvas);
|
|
150
|
+
this.orb.ctx = this.orb.canvas.getContext('2d');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Set canvas size
|
|
154
|
+
const size = Math.min(this.container.offsetWidth, this.container.offsetHeight) * 0.6;
|
|
155
|
+
this.orb.canvas.width = size;
|
|
156
|
+
this.orb.canvas.height = size;
|
|
157
|
+
|
|
158
|
+
// Initialize particles
|
|
159
|
+
this.initOrbParticles();
|
|
160
|
+
|
|
161
|
+
// Start animation
|
|
162
|
+
this.animateOrb();
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
initOrbParticles() {
|
|
166
|
+
this.orb.particles = [];
|
|
167
|
+
const count = 30;
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < count; i++) {
|
|
170
|
+
this.orb.particles.push({
|
|
171
|
+
angle: (Math.PI * 2 / count) * i,
|
|
172
|
+
radius: this.orb.baseRadius + Math.random() * 20,
|
|
173
|
+
speed: 0.01 + Math.random() * 0.02,
|
|
174
|
+
size: 2 + Math.random() * 4,
|
|
175
|
+
alpha: 0.3 + Math.random() * 0.7
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
animateOrb() {
|
|
181
|
+
if (this.currentMode !== 'orb') return;
|
|
182
|
+
|
|
183
|
+
const ctx = this.orb.ctx;
|
|
184
|
+
const canvas = this.orb.canvas;
|
|
185
|
+
const centerX = canvas.width / 2;
|
|
186
|
+
const centerY = canvas.height / 2;
|
|
187
|
+
|
|
188
|
+
// Clear canvas
|
|
189
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
190
|
+
|
|
191
|
+
// Get audio data if available
|
|
192
|
+
let audioLevel = 0;
|
|
193
|
+
if (window.audioAnalyser) {
|
|
194
|
+
const dataArray = new Uint8Array(window.audioAnalyser.frequencyBinCount);
|
|
195
|
+
window.audioAnalyser.getByteFrequencyData(dataArray);
|
|
196
|
+
// Average of bass frequencies
|
|
197
|
+
audioLevel = dataArray.slice(0, 10).reduce((a, b) => a + b, 0) / 10 / 255;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Get theme colors
|
|
201
|
+
const theme = window.ThemeManager?.getCurrentTheme() || {};
|
|
202
|
+
const primaryColor = theme.primary || '#0088ff';
|
|
203
|
+
const accentColor = theme.accent || '#00ffff';
|
|
204
|
+
|
|
205
|
+
// Pulsing effect
|
|
206
|
+
this.orb.pulsePhase += 0.02;
|
|
207
|
+
const pulse = Math.sin(this.orb.pulsePhase) * 0.1 + 1;
|
|
208
|
+
const audioPulse = 1 + audioLevel * 0.5;
|
|
209
|
+
const baseRadius = this.orb.baseRadius * pulse * audioPulse;
|
|
210
|
+
|
|
211
|
+
// Draw outer glow
|
|
212
|
+
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, baseRadius * 1.5);
|
|
213
|
+
gradient.addColorStop(0, this.hexToRgba(primaryColor, 0.3));
|
|
214
|
+
gradient.addColorStop(0.5, this.hexToRgba(accentColor, 0.1));
|
|
215
|
+
gradient.addColorStop(1, 'transparent');
|
|
216
|
+
|
|
217
|
+
ctx.beginPath();
|
|
218
|
+
ctx.arc(centerX, centerY, baseRadius * 1.5, 0, Math.PI * 2);
|
|
219
|
+
ctx.fillStyle = gradient;
|
|
220
|
+
ctx.fill();
|
|
221
|
+
|
|
222
|
+
// Draw core orb
|
|
223
|
+
const coreGradient = ctx.createRadialGradient(
|
|
224
|
+
centerX - baseRadius * 0.3,
|
|
225
|
+
centerY - baseRadius * 0.3,
|
|
226
|
+
0,
|
|
227
|
+
centerX,
|
|
228
|
+
centerY,
|
|
229
|
+
baseRadius
|
|
230
|
+
);
|
|
231
|
+
coreGradient.addColorStop(0, this.hexToRgba(accentColor, 0.8));
|
|
232
|
+
coreGradient.addColorStop(0.5, this.hexToRgba(primaryColor, 0.5));
|
|
233
|
+
coreGradient.addColorStop(1, this.hexToRgba(primaryColor, 0.2));
|
|
234
|
+
|
|
235
|
+
ctx.beginPath();
|
|
236
|
+
ctx.arc(centerX, centerY, baseRadius, 0, Math.PI * 2);
|
|
237
|
+
ctx.fillStyle = coreGradient;
|
|
238
|
+
ctx.fill();
|
|
239
|
+
|
|
240
|
+
// Draw border
|
|
241
|
+
ctx.strokeStyle = this.hexToRgba(accentColor, 0.6);
|
|
242
|
+
ctx.lineWidth = 2;
|
|
243
|
+
ctx.stroke();
|
|
244
|
+
|
|
245
|
+
// Animate particles
|
|
246
|
+
this.orb.particles.forEach(p => {
|
|
247
|
+
p.angle += p.speed * (1 + audioLevel * 2);
|
|
248
|
+
|
|
249
|
+
const wobble = Math.sin(this.orb.pulsePhase * 2 + p.angle) * 10 * audioLevel;
|
|
250
|
+
const x = centerX + Math.cos(p.angle) * (p.radius + wobble);
|
|
251
|
+
const y = centerY + Math.sin(p.angle) * (p.radius + wobble);
|
|
252
|
+
|
|
253
|
+
ctx.beginPath();
|
|
254
|
+
ctx.arc(x, y, p.size * audioPulse, 0, Math.PI * 2);
|
|
255
|
+
ctx.fillStyle = this.hexToRgba(accentColor, p.alpha);
|
|
256
|
+
ctx.fill();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Draw inner highlight
|
|
260
|
+
const highlightGradient = ctx.createRadialGradient(
|
|
261
|
+
centerX - baseRadius * 0.4,
|
|
262
|
+
centerY - baseRadius * 0.4,
|
|
263
|
+
0,
|
|
264
|
+
centerX - baseRadius * 0.4,
|
|
265
|
+
centerY - baseRadius * 0.4,
|
|
266
|
+
baseRadius * 0.5
|
|
267
|
+
);
|
|
268
|
+
highlightGradient.addColorStop(0, 'rgba(255, 255, 255, 0.4)');
|
|
269
|
+
highlightGradient.addColorStop(1, 'transparent');
|
|
270
|
+
|
|
271
|
+
ctx.beginPath();
|
|
272
|
+
ctx.arc(centerX - baseRadius * 0.3, centerY - baseRadius * 0.3, baseRadius * 0.4, 0, Math.PI * 2);
|
|
273
|
+
ctx.fillStyle = highlightGradient;
|
|
274
|
+
ctx.fill();
|
|
275
|
+
|
|
276
|
+
// Continue animation
|
|
277
|
+
this.animationFrame = requestAnimationFrame(() => this.animateOrb());
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
renderHaloSmoke() {
|
|
281
|
+
if (!window.HaloSmokeFace) {
|
|
282
|
+
console.warn('[FaceRenderer] HaloSmokeFace not loaded — add src/face/HaloSmokeFace.js to index.html');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// HaloSmokeFace.start() handles hiding eyes, removing old canvases, etc.
|
|
286
|
+
window.HaloSmokeFace.start(this.container);
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
hexToRgba(hex, alpha) {
|
|
290
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
291
|
+
if (!result) return `rgba(0, 136, 255, ${alpha})`;
|
|
292
|
+
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
updateOrbColors(colors) {
|
|
296
|
+
// Colors will be picked up in next animation frame
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
getCurrentMode() {
|
|
300
|
+
return this.currentMode;
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
getAvailableModes() {
|
|
304
|
+
return Object.keys(this.modes).map(key => ({
|
|
305
|
+
id: key,
|
|
306
|
+
...this.modes[key]
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlaylistEditor — Upload, reorder, and manage music tracks (P4-T2)
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Playlist tabs (library / generated)
|
|
6
|
+
* - Track list with drag-and-drop reordering
|
|
7
|
+
* - Upload new tracks (file picker + drag-and-drop zone)
|
|
8
|
+
* - Delete tracks with confirmation
|
|
9
|
+
* - Inline title edit + save
|
|
10
|
+
* - Save order button (persists via POST /api/music/playlist/<playlist>/order)
|
|
11
|
+
*
|
|
12
|
+
* Usage (standalone):
|
|
13
|
+
* import { PlaylistEditor } from './PlaylistEditor.js';
|
|
14
|
+
* const editor = new PlaylistEditor();
|
|
15
|
+
* editor.mount(document.getElementById('my-container'));
|
|
16
|
+
*
|
|
17
|
+
* Usage (via SettingsPanel):
|
|
18
|
+
* SettingsPanel.open('playlist');
|
|
19
|
+
*
|
|
20
|
+
* Backend endpoints used:
|
|
21
|
+
* GET /api/music?action=list&playlist=X
|
|
22
|
+
* POST /api/music/upload
|
|
23
|
+
* DELETE /api/music/track/<playlist>/<filename>
|
|
24
|
+
* PUT /api/music/track/<playlist>/<filename>/metadata
|
|
25
|
+
* GET /api/music/playlist/<playlist>/order
|
|
26
|
+
* POST /api/music/playlist/<playlist>/order
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export class PlaylistEditor {
|
|
30
|
+
constructor() {
|
|
31
|
+
this._root = null;
|
|
32
|
+
this._playlist = 'library';
|
|
33
|
+
this._tracks = [];
|
|
34
|
+
this._uploading = false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// -------------------------------------------------------------------------
|
|
38
|
+
// Public API
|
|
39
|
+
// -------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
async mount(container) {
|
|
42
|
+
this._root = container;
|
|
43
|
+
this._render();
|
|
44
|
+
await this._loadTracks();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
destroy() {
|
|
48
|
+
if (this._root) {
|
|
49
|
+
this._root.innerHTML = '';
|
|
50
|
+
this._root = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// -------------------------------------------------------------------------
|
|
55
|
+
// Render
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_render() {
|
|
59
|
+
this._root.innerHTML = `
|
|
60
|
+
<div class="pe-tabs">
|
|
61
|
+
<button class="pe-tab active" data-playlist="library">Library</button>
|
|
62
|
+
<button class="pe-tab" data-playlist="generated">Generated</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="pe-upload-zone" id="pe-upload-zone">
|
|
65
|
+
<span>🎵 Drop audio files here or
|
|
66
|
+
<label for="pe-file-input" class="pe-upload-link">browse</label>
|
|
67
|
+
</span>
|
|
68
|
+
<input type="file" id="pe-file-input"
|
|
69
|
+
accept=".mp3,.wav,.ogg,.m4a,.webm" multiple style="display:none">
|
|
70
|
+
<div class="pe-upload-status" id="pe-upload-status"></div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="pe-track-list" id="pe-track-list">
|
|
73
|
+
<div class="pe-loading">Loading tracks…</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="pe-footer">
|
|
76
|
+
<button class="pe-save-order-btn" id="pe-save-order">💾 Save Order</button>
|
|
77
|
+
<span class="pe-track-count" id="pe-track-count"></span>
|
|
78
|
+
</div>
|
|
79
|
+
`;
|
|
80
|
+
this._attachListeners();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_attachListeners() {
|
|
84
|
+
// Tab switching
|
|
85
|
+
this._root.querySelectorAll('.pe-tab').forEach(tab => {
|
|
86
|
+
tab.addEventListener('click', async () => {
|
|
87
|
+
this._root.querySelectorAll('.pe-tab').forEach(t => t.classList.remove('active'));
|
|
88
|
+
tab.classList.add('active');
|
|
89
|
+
this._playlist = tab.dataset.playlist;
|
|
90
|
+
await this._loadTracks();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// File input
|
|
95
|
+
const fileInput = this._root.querySelector('#pe-file-input');
|
|
96
|
+
fileInput.addEventListener('change', async (e) => {
|
|
97
|
+
if (e.target.files.length > 0) {
|
|
98
|
+
await this._uploadFiles(Array.from(e.target.files));
|
|
99
|
+
fileInput.value = '';
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Upload drop zone
|
|
104
|
+
const zone = this._root.querySelector('#pe-upload-zone');
|
|
105
|
+
zone.addEventListener('dragover', (e) => {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
zone.classList.add('drag-over');
|
|
108
|
+
});
|
|
109
|
+
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
|
110
|
+
zone.addEventListener('drop', async (e) => {
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
zone.classList.remove('drag-over');
|
|
113
|
+
const files = Array.from(e.dataTransfer.files)
|
|
114
|
+
.filter(f => /\.(mp3|wav|ogg|m4a|webm)$/i.test(f.name));
|
|
115
|
+
if (files.length > 0) await this._uploadFiles(files);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Save order button
|
|
119
|
+
this._root.querySelector('#pe-save-order').addEventListener('click', () => this._saveOrder());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -------------------------------------------------------------------------
|
|
123
|
+
// Track list
|
|
124
|
+
// -------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
async _loadTracks() {
|
|
127
|
+
const list = this._root.querySelector('#pe-track-list');
|
|
128
|
+
const count = this._root.querySelector('#pe-track-count');
|
|
129
|
+
list.innerHTML = '<div class="pe-loading">Loading…</div>';
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const resp = await fetch(`/api/music?action=list&playlist=${this._playlist}`);
|
|
133
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
134
|
+
const data = await resp.json();
|
|
135
|
+
this._tracks = data.tracks || [];
|
|
136
|
+
|
|
137
|
+
if (count) {
|
|
138
|
+
count.textContent = `${this._tracks.length} track${this._tracks.length !== 1 ? 's' : ''}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this._tracks.length === 0) {
|
|
142
|
+
list.innerHTML = '<div class="pe-empty">No tracks yet. Upload some audio files above!</div>';
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
list.innerHTML = '';
|
|
147
|
+
this._tracks.forEach((track, i) => {
|
|
148
|
+
list.appendChild(this._buildTrackRow(track, i));
|
|
149
|
+
});
|
|
150
|
+
this._initDragSort(list);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
list.innerHTML = `<div class="pe-error">Failed to load tracks: ${err.message}</div>`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_buildTrackRow(track) {
|
|
157
|
+
const row = document.createElement('div');
|
|
158
|
+
row.className = 'pe-track-row';
|
|
159
|
+
row.dataset.filename = track.filename;
|
|
160
|
+
row.draggable = true;
|
|
161
|
+
|
|
162
|
+
const title = track.title || track.name;
|
|
163
|
+
const artist = track.artist || '';
|
|
164
|
+
const fmt = track.format ? track.format.toUpperCase() : '';
|
|
165
|
+
const size = this._formatSize(track.size_bytes || 0);
|
|
166
|
+
|
|
167
|
+
row.innerHTML = `
|
|
168
|
+
<span class="pe-drag-handle" title="Drag to reorder">☰</span>
|
|
169
|
+
<div class="pe-track-info">
|
|
170
|
+
<input class="pe-track-title" value="${this._esc(title)}" placeholder="Track title">
|
|
171
|
+
<span class="pe-track-meta">${this._esc(artist)}${artist && fmt ? ' • ' : ''}${fmt}${fmt || artist ? ' • ' : ''}${size}</span>
|
|
172
|
+
</div>
|
|
173
|
+
<div class="pe-track-actions">
|
|
174
|
+
<button class="pe-btn-save" title="Save title">✔</button>
|
|
175
|
+
<button class="pe-btn-delete" title="Delete track">🗑</button>
|
|
176
|
+
</div>
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
row.querySelector('.pe-btn-save').addEventListener('click', async () => {
|
|
180
|
+
const input = row.querySelector('.pe-track-title');
|
|
181
|
+
await this._saveMeta(track.filename, { title: input.value.trim() });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
row.querySelector('.pe-btn-delete').addEventListener('click', async () => {
|
|
185
|
+
const displayTitle = row.querySelector('.pe-track-title').value || title;
|
|
186
|
+
if (!confirm(`Delete "${displayTitle}"?\nThis cannot be undone.`)) return;
|
|
187
|
+
await this._deleteTrack(track.filename);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Save on Enter key in title input
|
|
191
|
+
row.querySelector('.pe-track-title').addEventListener('keydown', async (e) => {
|
|
192
|
+
if (e.key === 'Enter') {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
await this._saveMeta(track.filename, { title: e.target.value.trim() });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return row;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// -------------------------------------------------------------------------
|
|
202
|
+
// Drag-and-drop reorder
|
|
203
|
+
// -------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
_initDragSort(list) {
|
|
206
|
+
let dragSrc = null;
|
|
207
|
+
|
|
208
|
+
list.addEventListener('dragstart', (e) => {
|
|
209
|
+
const row = e.target.closest('.pe-track-row');
|
|
210
|
+
if (!row) return;
|
|
211
|
+
dragSrc = row;
|
|
212
|
+
row.classList.add('pe-dragging');
|
|
213
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
list.addEventListener('dragover', (e) => {
|
|
217
|
+
e.preventDefault();
|
|
218
|
+
const row = e.target.closest('.pe-track-row');
|
|
219
|
+
if (!row || row === dragSrc) return;
|
|
220
|
+
list.querySelectorAll('.pe-track-row').forEach(r =>
|
|
221
|
+
r.classList.remove('pe-drag-above', 'pe-drag-below'));
|
|
222
|
+
const rect = row.getBoundingClientRect();
|
|
223
|
+
row.classList.add(e.clientY > rect.top + rect.height / 2 ? 'pe-drag-below' : 'pe-drag-above');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
list.addEventListener('dragleave', (e) => {
|
|
227
|
+
if (!list.contains(e.relatedTarget)) {
|
|
228
|
+
list.querySelectorAll('.pe-track-row').forEach(r =>
|
|
229
|
+
r.classList.remove('pe-drag-above', 'pe-drag-below'));
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
list.addEventListener('drop', (e) => {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
const target = e.target.closest('.pe-track-row');
|
|
236
|
+
list.querySelectorAll('.pe-track-row').forEach(r =>
|
|
237
|
+
r.classList.remove('pe-drag-above', 'pe-drag-below', 'pe-dragging'));
|
|
238
|
+
if (!target || !dragSrc || target === dragSrc) { dragSrc = null; return; }
|
|
239
|
+
const rect = target.getBoundingClientRect();
|
|
240
|
+
const after = e.clientY > rect.top + rect.height / 2;
|
|
241
|
+
list.insertBefore(dragSrc, after ? target.nextSibling : target);
|
|
242
|
+
dragSrc = null;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
list.addEventListener('dragend', () => {
|
|
246
|
+
list.querySelectorAll('.pe-track-row').forEach(r =>
|
|
247
|
+
r.classList.remove('pe-dragging', 'pe-drag-above', 'pe-drag-below'));
|
|
248
|
+
dragSrc = null;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
// API calls
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
async _uploadFiles(files) {
|
|
257
|
+
if (this._uploading) return;
|
|
258
|
+
this._uploading = true;
|
|
259
|
+
const status = this._root.querySelector('#pe-upload-status');
|
|
260
|
+
|
|
261
|
+
status.textContent = `Uploading ${files.length} file${files.length !== 1 ? 's' : ''}…`;
|
|
262
|
+
status.style.color = '';
|
|
263
|
+
|
|
264
|
+
let uploaded = 0;
|
|
265
|
+
for (const file of files) {
|
|
266
|
+
const fd = new FormData();
|
|
267
|
+
fd.append('file', file);
|
|
268
|
+
try {
|
|
269
|
+
const resp = await fetch('/api/music/upload', { method: 'POST', body: fd });
|
|
270
|
+
const data = await resp.json();
|
|
271
|
+
if (!resp.ok) {
|
|
272
|
+
this._showStatus(`Error: ${data.error || 'Upload failed'}`, true);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
uploaded++;
|
|
276
|
+
this._showStatus(`Uploading… (${uploaded}/${files.length})`);
|
|
277
|
+
} catch (err) {
|
|
278
|
+
this._showStatus(`Upload error: ${err.message}`, true);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this._uploading = false;
|
|
283
|
+
if (uploaded > 0) {
|
|
284
|
+
this._showStatus(`${uploaded} track${uploaded !== 1 ? 's' : ''} uploaded!`);
|
|
285
|
+
await this._loadTracks();
|
|
286
|
+
setTimeout(() => this._showStatus(''), 3000);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async _saveMeta(filename, fields) {
|
|
291
|
+
try {
|
|
292
|
+
const resp = await fetch(
|
|
293
|
+
`/api/music/track/${this._playlist}/${encodeURIComponent(filename)}/metadata`,
|
|
294
|
+
{
|
|
295
|
+
method: 'PUT',
|
|
296
|
+
headers: { 'Content-Type': 'application/json' },
|
|
297
|
+
body: JSON.stringify(fields),
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
301
|
+
this._showStatus('Saved!');
|
|
302
|
+
setTimeout(() => this._showStatus(''), 2000);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
this._showStatus(`Save failed: ${err.message}`, true);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async _deleteTrack(filename) {
|
|
309
|
+
try {
|
|
310
|
+
const resp = await fetch(
|
|
311
|
+
`/api/music/track/${this._playlist}/${encodeURIComponent(filename)}`,
|
|
312
|
+
{ method: 'DELETE' }
|
|
313
|
+
);
|
|
314
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
315
|
+
await this._loadTracks();
|
|
316
|
+
this._showStatus('Track deleted.');
|
|
317
|
+
setTimeout(() => this._showStatus(''), 2000);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
this._showStatus(`Delete failed: ${err.message}`, true);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async _saveOrder() {
|
|
324
|
+
const list = this._root.querySelector('#pe-track-list');
|
|
325
|
+
const rows = list.querySelectorAll('.pe-track-row');
|
|
326
|
+
const order = Array.from(rows).map(r => r.dataset.filename);
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const resp = await fetch(`/api/music/playlist/${this._playlist}/order`, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: { 'Content-Type': 'application/json' },
|
|
332
|
+
body: JSON.stringify({ order }),
|
|
333
|
+
});
|
|
334
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
335
|
+
this._showStatus('Order saved!');
|
|
336
|
+
setTimeout(() => this._showStatus(''), 2000);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
this._showStatus(`Order save failed: ${err.message}`, true);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -------------------------------------------------------------------------
|
|
343
|
+
// Helpers
|
|
344
|
+
// -------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
_showStatus(msg, isError = false) {
|
|
347
|
+
const el = this._root ? this._root.querySelector('#pe-upload-status') : null;
|
|
348
|
+
if (!el) return;
|
|
349
|
+
el.textContent = msg;
|
|
350
|
+
el.style.color = isError ? '#ff6666' : '#88ffaa';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
_formatSize(bytes) {
|
|
354
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
355
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
356
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
_esc(str) {
|
|
360
|
+
return String(str)
|
|
361
|
+
.replace(/&/g, '&')
|
|
362
|
+
.replace(/</g, '<')
|
|
363
|
+
.replace(/>/g, '>')
|
|
364
|
+
.replace(/"/g, '"');
|
|
365
|
+
}
|
|
366
|
+
}
|