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.
Files changed (185) hide show
  1. package/.env.example +104 -0
  2. package/Dockerfile +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +638 -0
  5. package/SETUP.md +360 -0
  6. package/app.py +232 -0
  7. package/auto-approve-devices.js +111 -0
  8. package/cli/index.js +372 -0
  9. package/config/__init__.py +4 -0
  10. package/config/default.yaml +43 -0
  11. package/config/flags.yaml +67 -0
  12. package/config/loader.py +203 -0
  13. package/config/providers.yaml +71 -0
  14. package/config/speech_normalization.yaml +182 -0
  15. package/config/theme.json +4 -0
  16. package/data/greetings.json +25 -0
  17. package/default-pages/ai-image-creator.html +915 -0
  18. package/default-pages/bulk-image-uploader.html +492 -0
  19. package/default-pages/desktop.html +2865 -0
  20. package/default-pages/file-explorer.html +854 -0
  21. package/default-pages/interactive-map.html +655 -0
  22. package/default-pages/style-guide.html +1005 -0
  23. package/default-pages/website-setup.html +1623 -0
  24. package/deploy/openclaw/Dockerfile +46 -0
  25. package/deploy/openvoiceui.service +30 -0
  26. package/deploy/setup-nginx.sh +50 -0
  27. package/deploy/setup-sudo.sh +306 -0
  28. package/deploy/skill-runner/Dockerfile +19 -0
  29. package/deploy/skill-runner/requirements.txt +14 -0
  30. package/deploy/skill-runner/server.py +269 -0
  31. package/deploy/supertonic/Dockerfile +22 -0
  32. package/deploy/supertonic/server.py +79 -0
  33. package/docker-compose.pinokio.yml +11 -0
  34. package/docker-compose.yml +59 -0
  35. package/greetings.json +25 -0
  36. package/index.html +65 -0
  37. package/inject-device-identity.js +142 -0
  38. package/package.json +82 -0
  39. package/profiles/default.json +114 -0
  40. package/profiles/manager.py +354 -0
  41. package/profiles/schema.json +337 -0
  42. package/prompts/voice-system-prompt.md +149 -0
  43. package/providers/__init__.py +39 -0
  44. package/providers/base.py +63 -0
  45. package/providers/llm/__init__.py +12 -0
  46. package/providers/llm/base.py +71 -0
  47. package/providers/llm/clawdbot_provider.py +112 -0
  48. package/providers/llm/zai_provider.py +115 -0
  49. package/providers/registry.py +320 -0
  50. package/providers/stt/__init__.py +12 -0
  51. package/providers/stt/base.py +58 -0
  52. package/providers/stt/webspeech_provider.py +49 -0
  53. package/providers/stt/whisper_provider.py +100 -0
  54. package/providers/tts/__init__.py +20 -0
  55. package/providers/tts/base.py +91 -0
  56. package/providers/tts/groq_provider.py +74 -0
  57. package/providers/tts/supertonic_provider.py +72 -0
  58. package/requirements.txt +38 -0
  59. package/routes/__init__.py +10 -0
  60. package/routes/admin.py +515 -0
  61. package/routes/canvas.py +1315 -0
  62. package/routes/chat.py +51 -0
  63. package/routes/conversation.py +2158 -0
  64. package/routes/elevenlabs_hybrid.py +306 -0
  65. package/routes/greetings.py +98 -0
  66. package/routes/icons.py +279 -0
  67. package/routes/image_gen.py +364 -0
  68. package/routes/instructions.py +190 -0
  69. package/routes/music.py +838 -0
  70. package/routes/onboarding.py +43 -0
  71. package/routes/pi.py +62 -0
  72. package/routes/profiles.py +215 -0
  73. package/routes/report_issue.py +68 -0
  74. package/routes/static_files.py +533 -0
  75. package/routes/suno.py +664 -0
  76. package/routes/theme.py +81 -0
  77. package/routes/transcripts.py +199 -0
  78. package/routes/vision.py +348 -0
  79. package/routes/workspace.py +288 -0
  80. package/server.py +1510 -0
  81. package/services/__init__.py +1 -0
  82. package/services/auth.py +143 -0
  83. package/services/canvas_versioning.py +239 -0
  84. package/services/db_pool.py +107 -0
  85. package/services/gateway.py +16 -0
  86. package/services/gateway_manager.py +333 -0
  87. package/services/gateways/__init__.py +12 -0
  88. package/services/gateways/base.py +110 -0
  89. package/services/gateways/compat.py +264 -0
  90. package/services/gateways/openclaw.py +1134 -0
  91. package/services/health.py +100 -0
  92. package/services/memory_client.py +455 -0
  93. package/services/paths.py +26 -0
  94. package/services/speech_normalizer.py +285 -0
  95. package/services/tts.py +270 -0
  96. package/setup-config.js +262 -0
  97. package/sounds/air_horn.mp3 +0 -0
  98. package/sounds/bruh.mp3 +0 -0
  99. package/sounds/crowd_cheer.mp3 +0 -0
  100. package/sounds/gunshot.mp3 +0 -0
  101. package/sounds/impact.mp3 +0 -0
  102. package/sounds/lets_go.mp3 +0 -0
  103. package/sounds/record_stop.mp3 +0 -0
  104. package/sounds/rewind.mp3 +0 -0
  105. package/sounds/sad_trombone.mp3 +0 -0
  106. package/sounds/scratch_long.mp3 +0 -0
  107. package/sounds/yeah.mp3 +0 -0
  108. package/src/adapters/ClawdBotAdapter.js +264 -0
  109. package/src/adapters/_template.js +133 -0
  110. package/src/adapters/elevenlabs-classic.js +841 -0
  111. package/src/adapters/elevenlabs-hybrid.js +812 -0
  112. package/src/adapters/hume-evi.js +676 -0
  113. package/src/admin.html +1339 -0
  114. package/src/app.js +8802 -0
  115. package/src/core/Config.js +173 -0
  116. package/src/core/EmotionEngine.js +307 -0
  117. package/src/core/EventBridge.js +180 -0
  118. package/src/core/EventBus.js +117 -0
  119. package/src/core/VoiceSession.js +607 -0
  120. package/src/face/BaseFace.js +259 -0
  121. package/src/face/EyeFace.js +208 -0
  122. package/src/face/HaloSmokeFace.js +509 -0
  123. package/src/face/manifest.json +27 -0
  124. package/src/face/previews/eyes.svg +16 -0
  125. package/src/face/previews/orb.svg +29 -0
  126. package/src/features/MusicPlayer.js +620 -0
  127. package/src/features/Soundboard.js +128 -0
  128. package/src/providers/DeepgramSTT.js +472 -0
  129. package/src/providers/DeepgramStreamingSTT.js +766 -0
  130. package/src/providers/GroqSTT.js +559 -0
  131. package/src/providers/TTSPlayer.js +323 -0
  132. package/src/providers/WebSpeechSTT.js +479 -0
  133. package/src/providers/tts/BaseTTSProvider.js +81 -0
  134. package/src/providers/tts/HumeProvider.js +77 -0
  135. package/src/providers/tts/SupertonicProvider.js +174 -0
  136. package/src/providers/tts/index.js +140 -0
  137. package/src/shell/adapter-registry.js +154 -0
  138. package/src/shell/caller-bridge.js +35 -0
  139. package/src/shell/camera-bridge.js +28 -0
  140. package/src/shell/canvas-bridge.js +32 -0
  141. package/src/shell/commercial-bridge.js +44 -0
  142. package/src/shell/face-bridge.js +44 -0
  143. package/src/shell/music-bridge.js +60 -0
  144. package/src/shell/orchestrator.js +233 -0
  145. package/src/shell/profile-discovery.js +303 -0
  146. package/src/shell/sounds-bridge.js +28 -0
  147. package/src/shell/transcript-bridge.js +61 -0
  148. package/src/shell/waveform-bridge.js +33 -0
  149. package/src/styles/base.css +2862 -0
  150. package/src/styles/face.css +417 -0
  151. package/src/styles/pi-overrides.css +89 -0
  152. package/src/styles/theme-dark.css +67 -0
  153. package/src/test-tts.html +175 -0
  154. package/src/ui/AppShell.js +544 -0
  155. package/src/ui/ProfileSwitcher.js +228 -0
  156. package/src/ui/SessionControl.js +240 -0
  157. package/src/ui/face/FacePicker.js +195 -0
  158. package/src/ui/face/FaceRenderer.js +309 -0
  159. package/src/ui/settings/PlaylistEditor.js +366 -0
  160. package/src/ui/settings/SettingsPanel.css +684 -0
  161. package/src/ui/settings/SettingsPanel.js +419 -0
  162. package/src/ui/settings/TTSVoicePreview.js +210 -0
  163. package/src/ui/themes/ThemeManager.js +213 -0
  164. package/src/ui/visualizers/BaseVisualizer.js +29 -0
  165. package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
  166. package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
  167. package/static/emulators/jsdos/js-dos.css +1 -0
  168. package/static/emulators/jsdos/js-dos.js +22 -0
  169. package/static/favicon.svg +55 -0
  170. package/static/icons/apple-touch-icon.png +0 -0
  171. package/static/icons/favicon-32.png +0 -0
  172. package/static/icons/icon-192.png +0 -0
  173. package/static/icons/icon-512.png +0 -0
  174. package/static/install.html +449 -0
  175. package/static/manifest.json +26 -0
  176. package/static/sw.js +21 -0
  177. package/tts_providers/__init__.py +136 -0
  178. package/tts_providers/base_provider.py +319 -0
  179. package/tts_providers/groq_provider.py +155 -0
  180. package/tts_providers/hume_provider.py +226 -0
  181. package/tts_providers/providers_config.json +119 -0
  182. package/tts_providers/qwen3_provider.py +371 -0
  183. package/tts_providers/resemble_provider.py +315 -0
  184. package/tts_providers/supertonic_provider.py +557 -0
  185. 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>&#x1F3B5; 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&#x2026;</div>
74
+ </div>
75
+ <div class="pe-footer">
76
+ <button class="pe-save-order-btn" id="pe-save-order">&#x1F4BE; 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&#x2026;</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">&#x2630;</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 ? ' &bull; ' : ''}${fmt}${fmt || artist ? ' &bull; ' : ''}${size}</span>
172
+ </div>
173
+ <div class="pe-track-actions">
174
+ <button class="pe-btn-save" title="Save title">&#x2714;</button>
175
+ <button class="pe-btn-delete" title="Delete track">&#x1F5D1;</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, '&amp;')
362
+ .replace(/</g, '&lt;')
363
+ .replace(/>/g, '&gt;')
364
+ .replace(/"/g, '&quot;');
365
+ }
366
+ }