themehmi commited on
Commit
4606ede
·
verified ·
1 Parent(s): 7e558fb

Upload 5 files

Browse files
Files changed (3) hide show
  1. README_DOCKER.md +127 -0
  2. app.py +60 -16
  3. templates/index.html +223 -2
README_DOCKER.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚗 Safe Driving Assistant — Docker Integration Guide
2
+
3
+ Welcome to the premium Docker integration guide for your **Safe Driving Assistant**. This guide provides step-by-step instructions on how to package, configure, build, and run the assistant inside a fully isolated, headless-capable Docker container.
4
+
5
+ The containerized environment includes all C/C++ libraries, graphics backends, sound drivers, and speech synthesizers, pre-configured with a virtual framebuffer (**Xvfb**) to prevent OpenCV graphical crashes.
6
+
7
+ ---
8
+
9
+ ## 🌟 Highlights of the Docker Configuration
10
+
11
+ * **Zero-Dependency Setup:** No need to install `dlib`, `cmake`, `opencv`, or `pyaudio` manually on your host machine.
12
+ * **Virtual Framebuffer (Xvfb):** Headless-safe. If no display is detected, it automatically spawns a virtual X11 server so that `cv2.imshow` calls do not crash.
13
+ * **Dynamic Network Routing:** Pre-configured to easily bridge to your local **Ollama** SLM instance.
14
+ * **Unified Sound Backend:** Installs PulseAudio, ALSA, and `espeak` dependencies required for speech synthesis and playback.
15
+ * **Environment-Driven Configuration:** Highly customizable via environment variables (`FLASK_HOST`, `CAMERA_ID`, `OLLAMA_API_URL`, etc.).
16
+
17
+ ---
18
+
19
+ ## 🛠️ Step 1: Build the Docker Image
20
+
21
+ Open your terminal in the project directory (`Safe Driving Assistant`) and run the following command to build your custom co-pilot image:
22
+
23
+ ```bash
24
+ docker build -t safe-driving-assistant:latest .
25
+ ```
26
+
27
+ *This compilation will take several minutes during the first run because it builds `dlib` (facial recognition) and compiles native C extensions.*
28
+
29
+ ---
30
+
31
+ ## 🚀 Step 2: Run the Docker Container
32
+
33
+ Depending on your host Operating System and hardware setup, select one of the premium run configurations below:
34
+
35
+ ### Option A: Standard Headless / Web-HUD Only (Recommended)
36
+ This runs the assistant, serves the live futuristic Web-HUD on port `5000`, and bridges network queries to your host's local Ollama service.
37
+
38
+ ```bash
39
+ docker run -d \
40
+ --name driving_assistant \
41
+ --add-host=host.docker.internal:host-gateway \
42
+ -e OLLAMA_API_URL="http://host.docker.internal:11434/api/generate" \
43
+ -p 5000:5000 \
44
+ safe-driving-assistant:latest
45
+ ```
46
+
47
+ * **Web Dashboard:** Once started, open your browser and navigate to **`http://localhost:5000`** to view the live dashboard!
48
+ * **How it works:** The container runs headlessly. Face recognition and eye-aspect ratio tracking are active, and the live stream is pushed directly to the dashboard.
49
+
50
+ ---
51
+
52
+ ### Option B: Linux Run with Full Hardware Passthrough (Camera & Audio)
53
+ If you are running native Linux and want the container to access your physical USB webcam and audio hardware directly, run:
54
+
55
+ ```bash
56
+ docker run -it \
57
+ --name driving_assistant \
58
+ --device /dev/video0:/dev/video0 \
59
+ --device /dev/snd:/dev/snd \
60
+ --add-host=host.docker.internal:host-gateway \
61
+ -e CAMERA_ID=0 \
62
+ -e OLLAMA_API_URL="http://host.docker.internal:11434/api/generate" \
63
+ -p 5000:5000 \
64
+ safe-driving-assistant:latest
65
+ ```
66
+
67
+ * **`--device /dev/video0`:** Mounts your primary webcam inside the container.
68
+ * **`--device /dev/snd`:** Exposes speaker and mic controls.
69
+
70
+ ---
71
+
72
+ ### Option C: Windows Host (WSL2) with Web USB Webcam Passthrough
73
+ If you are using WSL2 on Windows and want to feed your physical webcam into the Docker container:
74
+
75
+ 1. Bind your USB camera to WSL2 using [usbipd-win](https://github.com/dorssel/usbipd-win):
76
+ ```powershell
77
+ usbipd list
78
+ usbipd bind --busid <BUSID>
79
+ usbipd attach --wsl --busid <BUSID>
80
+ ```
81
+ 2. Start the container with device mapping:
82
+ ```bash
83
+ docker run -it \
84
+ --name driving_assistant \
85
+ --device /dev/video0:/dev/video0 \
86
+ --add-host=host.docker.internal:host-gateway \
87
+ -e CAMERA_ID=0 \
88
+ -e OLLAMA_API_URL="http://host.docker.internal:11434/api/generate" \
89
+ -p 5000:5000 \
90
+ safe-driving-assistant:latest
91
+ ```
92
+
93
+ ---
94
+
95
+ ## ⚙️ Environment Variables Customization
96
+
97
+ You can dynamically tune your assistant container at runtime by passing `-e KEY=VALUE` parameters to `docker run`:
98
+
99
+ | Variable Name | Default Value | Description |
100
+ | :--- | :--- | :--- |
101
+ | `FLASK_HOST` | `0.0.0.0` | Binding IP address for Flask web dashboard. |
102
+ | `FLASK_PORT` | `5000` | Port on which the web HUD dashboard will be served. |
103
+ | `CAMERA_ID` | `0` | Camera index. |
104
+ | `OLLAMA_API_URL` | `http://localhost:11434/api/generate` | The API endpoint for the Ollama voice co-pilot. Use `http://host.docker.internal:11434/api/generate` for bridging to host. |
105
+ | `OLLAMA_MODEL` | `drivesafe` | Name of the custom SLM model configured inside Ollama. |
106
+ | `FRAME_WIDTH` | `640` | Video frame capture width. |
107
+ | `FRAME_HEIGHT` | `480` | Video frame capture height. |
108
+
109
+ ---
110
+
111
+ ## 🧼 Housekeeping & Diagnostics
112
+
113
+ ### View Real-Time Logs
114
+ To see the system warnings, detected EAR, and conversational chatbot interactions:
115
+ ```bash
116
+ docker logs -f driving_assistant
117
+ ```
118
+
119
+ ### Stop & Remove Container
120
+ To stop and clean up the assistant container instance:
121
+ ```bash
122
+ docker stop driving_assistant
123
+ docker rm driving_assistant
124
+ ```
125
+
126
+ ---
127
+ **Safe Driving Assistant** — *Keep your eyes on the road, your hands on the wheel, and drive safely!* 🚗💨
app.py CHANGED
@@ -27,6 +27,7 @@ import re
27
  from dotenv import load_dotenv
28
  import urllib.parse
29
  import urllib.request
 
30
 
31
  load_dotenv()
32
 
@@ -77,6 +78,25 @@ def index():
77
  def api_status():
78
  return jsonify(system_status)
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  @app.route('/api/frame', methods=['POST'])
81
  def api_frame():
82
  global start_closed_time, last_announced_state
@@ -195,13 +215,12 @@ def api_voice():
195
  html = urllib.request.urlopen(f'https://www.youtube.com/results?search_query={query}').read().decode()
196
  video_ids = re.findall(r'watch\?v=(\S{11})', html)
197
  if video_ids:
198
- url = f'https://music.youtube.com/watch?v={video_ids[0]}'
199
- return jsonify({"speak": f"Playing {stripped} on YouTube Music to keep you alert.", "action": "open_url", "url": url})
200
  else:
201
  return jsonify({"speak": "I couldn't find that song."})
202
  except Exception as e:
203
  print(f"[YouTube Search Error] {repr(e)}")
204
- return jsonify({"speak": "I had trouble searching for the song on YouTube. It might be blocking automated requests."})
205
  return jsonify({})
206
 
207
  # Normal wake-word logic
@@ -212,9 +231,9 @@ def api_voice():
212
  active_listening = False
213
  return jsonify({"speak": "Goodbye."})
214
  else:
215
- if 'eliot' not in text:
216
  return jsonify({})
217
- idx = text.find('eliot') + len('eliot')
218
  command = re.sub(r'[^\w\s]', '', text[idx:]).strip()
219
 
220
  if len(command) < 3:
@@ -223,26 +242,43 @@ def api_voice():
223
 
224
  print(f'[Voice API] Command: "{command}"')
225
 
226
- # Media Commands (Cloud servers can't press media keys, so we handle it gracefully)
227
- is_media_control = command.lower() in ['pause', 'stop', 'play', 'resume']
228
- if is_media_control:
229
- return jsonify({"speak": "Media controls are not supported in the web browser."})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
  if command.lower().startswith('play '):
232
  song = command[5:].strip()
 
 
233
  if song:
234
  try:
235
  query = urllib.parse.quote_plus(song)
236
  html = urllib.request.urlopen(f'https://www.youtube.com/results?search_query={query}').read().decode()
237
  video_ids = re.findall(r'watch\?v=(\S{11})', html)
238
  if video_ids:
239
- url = f'https://music.youtube.com/watch?v={video_ids[0]}'
240
- return jsonify({"speak": f"Playing {song} on YouTube Music.", "action": "open_url", "url": url})
241
  else:
242
  return jsonify({"speak": "I couldn't find that song."})
243
  except Exception as e:
244
  print(f"[YouTube Search Error] {repr(e)}")
245
- return jsonify({"speak": "I had trouble searching for the song on YouTube. It might be blocking automated requests."})
246
 
247
  # LLM Interaction
248
  api_key = os.getenv('API_KEY', '')
@@ -252,9 +288,11 @@ def api_voice():
252
  try:
253
  llm = OpenAI(api_key=api_key, base_url='https://integrate.api.nvidia.com/v1')
254
  SYS_PROMPT = (
255
- 'You are eliot, a helpful voice assistant. '
256
- 'Reply in plain text only no markdown, no bullet points. '
257
- 'Be very concise: 1-2 sentences maximum.'
 
 
258
  )
259
  resp = llm.chat.completions.create(
260
  model='meta/llama-3.1-8b-instruct',
@@ -288,7 +326,13 @@ def api_chat():
288
 
289
  try:
290
  client = OpenAI(api_key=api_key, base_url="https://integrate.api.nvidia.com/v1")
291
- sys_prompt = "You are a helpful assistant. Answer clearly and concisely."
 
 
 
 
 
 
292
  completion = client.chat.completions.create(
293
  model='meta/llama-3.1-8b-instruct',
294
  max_tokens=300,
 
27
  from dotenv import load_dotenv
28
  import urllib.parse
29
  import urllib.request
30
+ import subprocess
31
 
32
  load_dotenv()
33
 
 
78
  def api_status():
79
  return jsonify(system_status)
80
 
81
+ @app.route('/api/music_url', methods=['GET'])
82
+ def api_music_url():
83
+ video_id = request.args.get('video_id')
84
+ if not video_id:
85
+ return jsonify({"error": "No video_id provided"}), 400
86
+ try:
87
+ cmd = ["yt-dlp", "-J", "-f", "bestaudio", f"https://www.youtube.com/watch?v={video_id}"]
88
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
89
+ info = json.loads(result.stdout)
90
+ return jsonify({
91
+ "url": info.get("url"),
92
+ "title": info.get("title"),
93
+ "artist": info.get("uploader"),
94
+ "thumbnail": info.get("thumbnail")
95
+ })
96
+ except Exception as e:
97
+ print(f"[yt-dlp error] {repr(e)}")
98
+ return jsonify({"error": str(e)}), 500
99
+
100
  @app.route('/api/frame', methods=['POST'])
101
  def api_frame():
102
  global start_closed_time, last_announced_state
 
215
  html = urllib.request.urlopen(f'https://www.youtube.com/results?search_query={query}').read().decode()
216
  video_ids = re.findall(r'watch\?v=(\S{11})', html)
217
  if video_ids:
218
+ return jsonify({"speak": f"Playing {stripped} to keep you alert.", "action": "play_native", "video_ids": video_ids[:20], "title": stripped})
 
219
  else:
220
  return jsonify({"speak": "I couldn't find that song."})
221
  except Exception as e:
222
  print(f"[YouTube Search Error] {repr(e)}")
223
+ return jsonify({"speak": "I had trouble searching for the song on YouTube Music. It might be blocking automated requests."})
224
  return jsonify({})
225
 
226
  # Normal wake-word logic
 
231
  active_listening = False
232
  return jsonify({"speak": "Goodbye."})
233
  else:
234
+ if 'lara' not in text:
235
  return jsonify({})
236
+ idx = text.find('lara') + len('lara')
237
  command = re.sub(r'[^\w\s]', '', text[idx:]).strip()
238
 
239
  if len(command) < 3:
 
242
 
243
  print(f'[Voice API] Command: "{command}"')
244
 
245
+ # Media Commands (Native Player Control)
246
+ cmd = command.lower()
247
+ if 'pause' in cmd:
248
+ return jsonify({"speak": "Pausing.", "action": "pause_native"})
249
+ if 'resume' in cmd or cmd == 'play':
250
+ return jsonify({"speak": "Resuming.", "action": "resume_native"})
251
+ if 'stop' in cmd:
252
+ return jsonify({"speak": "Stopping.", "action": "stop_native"})
253
+ if 'next' in cmd or 'skip' in cmd:
254
+ return jsonify({"speak": "Skipping.", "action": "next_native"})
255
+ if 'previous' in cmd or 'back' in cmd:
256
+ return jsonify({"speak": "Going back.", "action": "prev_native"})
257
+ if 'volume up' in cmd or 'louder' in cmd or 'increase volume' in cmd:
258
+ return jsonify({"speak": "Volume up.", "action": "vol_up_native"})
259
+ if 'volume down' in cmd or 'quieter' in cmd or 'decrease volume' in cmd:
260
+ return jsonify({"speak": "Volume down.", "action": "vol_down_native"})
261
+ if 'unmute' in cmd:
262
+ return jsonify({"speak": "Unmuting.", "action": "unmute_native"})
263
+ elif 'mute' in cmd:
264
+ return jsonify({"speak": "Muting.", "action": "mute_native"})
265
 
266
  if command.lower().startswith('play '):
267
  song = command[5:].strip()
268
+ if song.lower() in ['music', 'song', 'the music', 'the song', 'it']:
269
+ return jsonify({"speak": "Resuming.", "action": "resume_native"})
270
  if song:
271
  try:
272
  query = urllib.parse.quote_plus(song)
273
  html = urllib.request.urlopen(f'https://www.youtube.com/results?search_query={query}').read().decode()
274
  video_ids = re.findall(r'watch\?v=(\S{11})', html)
275
  if video_ids:
276
+ return jsonify({"speak": f"Playing {song}.", "action": "play_native", "video_ids": video_ids[:20], "title": song})
 
277
  else:
278
  return jsonify({"speak": "I couldn't find that song."})
279
  except Exception as e:
280
  print(f"[YouTube Search Error] {repr(e)}")
281
+ return jsonify({"speak": "I had trouble searching for the song on YouTube Music. It might be blocking automated requests."})
282
 
283
  # LLM Interaction
284
  api_key = os.getenv('API_KEY', '')
 
288
  try:
289
  llm = OpenAI(api_key=api_key, base_url='https://integrate.api.nvidia.com/v1')
290
  SYS_PROMPT = (
291
+ 'You are Lara, a voice assistant and copilot for a driver. '
292
+ 'You MUST ONLY assist with driving, road-related questions, directions, routes, and playing music. '
293
+ 'If the user asks about ANYTHING else, politely refuse to answer and state that you can only help with driving and music. '
294
+ 'Provide helpful, concise, and clear answers so the driver can stay focused on the road. '
295
+ 'Keep your responses brief (1-2 sentences max) and do not use conversational filler.'
296
  )
297
  resp = llm.chat.completions.create(
298
  model='meta/llama-3.1-8b-instruct',
 
326
 
327
  try:
328
  client = OpenAI(api_key=api_key, base_url="https://integrate.api.nvidia.com/v1")
329
+ sys_prompt = (
330
+ 'You are Lara, a voice assistant and copilot for a driver. '
331
+ 'You MUST ONLY assist with driving, road-related questions, directions, routes, and playing music. '
332
+ 'If the user asks about ANYTHING else, politely refuse to answer and state that you can only help with driving and music. '
333
+ 'Provide helpful, concise, and clear answers so the driver can stay focused on the road. '
334
+ 'Keep your responses brief (1-2 sentences max) and do not use conversational filler.'
335
+ )
336
  completion = client.chat.completions.create(
337
  model='meta/llama-3.1-8b-instruct',
338
  max_tokens=300,
templates/index.html CHANGED
@@ -466,6 +466,100 @@
466
  justify-content: center;
467
  }
468
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  </style>
470
  </head>
471
 
@@ -499,6 +593,28 @@
499
  <strong id="drowsyEvents" style="color: var(--text-main);">0</strong>
500
  </div>
501
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  </aside>
503
 
504
  <!-- Main Content -->
@@ -520,6 +636,11 @@
520
  </div>
521
  </div>
522
 
 
 
 
 
 
523
  <!-- Chat Tab -->
524
  <div class="tab-content" id="chat-tab">
525
  <div class="chat-area" id="chatHistory">
@@ -540,6 +661,7 @@
540
  </main>
541
  </div>
542
 
 
543
  <script>
544
  // Tab Navigation
545
  const tabBtns = document.querySelectorAll('.tab-btn');
@@ -584,6 +706,71 @@
584
  let isHardwareActive = false;
585
  let isSpeaking = false;
586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  // TTS Engine
588
  function speakOutLoud(text) {
589
  if (!text) return;
@@ -637,11 +824,45 @@
637
  if (data.speak) {
638
  speakOutLoud(data.speak);
639
  }
640
- if (data.action === "open_url" && data.url) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
641
  let newWin = window.open(data.url, "_blank");
642
  if (!newWin || newWin.closed || typeof newWin.closed == 'undefined') {
643
  // Popup blocked
644
- appendMessage('system', `⚠️ **Popup Blocked**<br>I tried to open the song, but your browser blocked it.<br><a href="${data.url}" target="_blank" style="color:var(--primary); font-weight:bold; font-size:1.1rem;">👉 Click Here to Open YouTube Music</a><br>*(To make this automatic, click the popup-blocker icon in your browser's address bar and select "Always allow popups from this site")*`);
645
  }
646
  }
647
  if (data.text) {
 
466
  justify-content: center;
467
  }
468
  }
469
+
470
+ /* Modern Media Player */
471
+ .modern-media-player {
472
+ background: #2a2a2a;
473
+ border-radius: 24px;
474
+ padding: 10px 14px;
475
+ display: flex;
476
+ align-items: center;
477
+ gap: 14px;
478
+ color: white;
479
+ position: relative;
480
+ margin-top: 1.5rem;
481
+ box-shadow: 0 10px 30px rgba(0,0,0,0.5);
482
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
483
+ }
484
+ .mmp-art {
485
+ width: 50px;
486
+ height: 50px;
487
+ border-radius: 12px;
488
+ overflow: hidden;
489
+ flex-shrink: 0;
490
+ box-shadow: 0 4px 10px rgba(0,0,0,0.3);
491
+ }
492
+ .mmp-art img {
493
+ width: 100%;
494
+ height: 100%;
495
+ object-fit: cover;
496
+ }
497
+ .mmp-info {
498
+ flex-grow: 1;
499
+ display: flex;
500
+ flex-direction: column;
501
+ justify-content: center;
502
+ min-width: 0;
503
+ }
504
+ .mmp-source {
505
+ font-size: 0.7rem;
506
+ color: #b3b3b3;
507
+ display: flex;
508
+ align-items: center;
509
+ gap: 5px;
510
+ margin-bottom: 2px;
511
+ }
512
+ .mmp-title {
513
+ font-size: 0.95rem;
514
+ font-weight: 600;
515
+ white-space: nowrap;
516
+ overflow: hidden;
517
+ text-overflow: ellipsis;
518
+ margin-bottom: 1px;
519
+ color: #ffffff;
520
+ }
521
+ .mmp-artist {
522
+ font-size: 0.8rem;
523
+ color: #b3b3b3;
524
+ white-space: nowrap;
525
+ overflow: hidden;
526
+ text-overflow: ellipsis;
527
+ }
528
+ .mmp-controls {
529
+ display: flex;
530
+ align-items: center;
531
+ gap: 12px;
532
+ margin-right: 18px;
533
+ }
534
+ .mmp-btn {
535
+ background: none;
536
+ border: none;
537
+ color: white;
538
+ font-size: 1.1rem;
539
+ cursor: pointer;
540
+ display: flex;
541
+ align-items: center;
542
+ justify-content: center;
543
+ padding: 4px;
544
+ transition: transform 0.1s;
545
+ }
546
+ .mmp-btn:active {
547
+ transform: scale(0.9);
548
+ }
549
+ .mmp-btn.play-pause {
550
+ font-size: 1.4rem;
551
+ }
552
+ .mmp-btn:hover {
553
+ color: #e0e0e0;
554
+ }
555
+ .mmp-expand {
556
+ position: absolute;
557
+ top: 14px;
558
+ right: 14px;
559
+ color: #b3b3b3;
560
+ font-size: 0.85rem;
561
+ cursor: pointer;
562
+ }
563
  </style>
564
  </head>
565
 
 
593
  <strong id="drowsyEvents" style="color: var(--text-main);">0</strong>
594
  </div>
595
  </div>
596
+
597
+ <!-- MODERN MEDIA PLAYER -->
598
+ <div class="modern-media-player" id="custom-media-player" style="display:none;">
599
+ <div class="mmp-art">
600
+ <img src="https://via.placeholder.com/60" alt="Album Art" id="mmp-thumbnail">
601
+ </div>
602
+ <div class="mmp-info">
603
+ <div class="mmp-source">
604
+ <i class="fa-regular fa-circle-play"></i> This copilot
605
+ </div>
606
+ <div class="mmp-title" id="mmp-title">Unknown Song</div>
607
+ <div class="mmp-artist" id="mmp-artist">Unknown Artist</div>
608
+ </div>
609
+ <div class="mmp-controls">
610
+ <button class="mmp-btn" id="mmp-prev" onclick="if(ytPlayer && typeof ytPlayer.previousVideo === 'function') ytPlayer.previousVideo()"><i class="fa-solid fa-backward-step"></i></button>
611
+ <button class="mmp-btn play-pause" id="mmp-play-pause" onclick="toggleMmpPlay()"><i class="fa-solid fa-pause"></i></button>
612
+ <button class="mmp-btn" id="mmp-next" onclick="if(ytPlayer && typeof ytPlayer.nextVideo === 'function') ytPlayer.nextVideo()"><i class="fa-solid fa-forward-step"></i></button>
613
+ </div>
614
+ <div class="mmp-expand">
615
+ <i class="fa-solid fa-chevron-down"></i>
616
+ </div>
617
+ </div>
618
  </aside>
619
 
620
  <!-- Main Content -->
 
636
  </div>
637
  </div>
638
 
639
+
640
+
641
+ <!-- NATIVE AUDIO PLAYER -->
642
+ <audio id="native-audio" style="display:none;"></audio>
643
+
644
  <!-- Chat Tab -->
645
  <div class="tab-content" id="chat-tab">
646
  <div class="chat-area" id="chatHistory">
 
661
  </main>
662
  </div>
663
 
664
+ <!-- No external iframe scripts needed -->
665
  <script>
666
  // Tab Navigation
667
  const tabBtns = document.querySelectorAll('.tab-btn');
 
706
  let isHardwareActive = false;
707
  let isSpeaking = false;
708
 
709
+
710
+ // --- NATIVE AUDIO API ---
711
+ const nativeAudio = document.getElementById('native-audio');
712
+ let isMmpPlaying = false;
713
+
714
+ nativeAudio.addEventListener('play', () => {
715
+ document.getElementById('mmp-play-pause').innerHTML = '<i class="fa-solid fa-pause"></i>';
716
+ isMmpPlaying = true;
717
+ });
718
+
719
+ nativeAudio.addEventListener('pause', () => {
720
+ document.getElementById('mmp-play-pause').innerHTML = '<i class="fa-solid fa-play"></i>';
721
+ isMmpPlaying = false;
722
+ });
723
+
724
+ nativeAudio.addEventListener('ended', () => {
725
+ document.getElementById('mmp-play-pause').innerHTML = '<i class="fa-solid fa-play"></i>';
726
+ isMmpPlaying = false;
727
+ if (currentQueue.length > currentQueueIndex + 1) {
728
+ currentQueueIndex++;
729
+ playNativeAudio(currentQueue[currentQueueIndex], "Up Next...");
730
+ }
731
+ });
732
+
733
+ let currentQueue = [];
734
+ let currentQueueIndex = 0;
735
+
736
+ function toggleMmpPlay() {
737
+ if (isMmpPlaying) {
738
+ nativeAudio.pause();
739
+ } else {
740
+ nativeAudio.play();
741
+ }
742
+ }
743
+
744
+ async function playNativeAudio(videoId, titleGuess) {
745
+ document.getElementById('custom-media-player').style.display = 'flex';
746
+ document.getElementById('mmp-thumbnail').src = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
747
+ document.getElementById('mmp-title').innerText = "Extracting Stream...";
748
+ document.getElementById('mmp-artist').innerText = titleGuess || "Loading";
749
+ document.getElementById('mmp-play-pause').innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
750
+
751
+ try {
752
+ const res = await fetch(`/api/music_url?video_id=${videoId}`);
753
+ const data = await res.json();
754
+ if (data.url) {
755
+ nativeAudio.src = data.url;
756
+ nativeAudio.volume = 1.0;
757
+ nativeAudio.play();
758
+ document.getElementById('mmp-title').innerText = data.title || titleGuess;
759
+ document.getElementById('mmp-artist').innerText = data.artist || "yt-dlp Engine";
760
+ if (data.thumbnail) {
761
+ document.getElementById('mmp-thumbnail').src = data.thumbnail;
762
+ }
763
+ } else {
764
+ document.getElementById('mmp-title').innerText = "Stream Failed";
765
+ document.getElementById('mmp-play-pause').innerHTML = '<i class="fa-solid fa-xmark"></i>';
766
+ }
767
+ } catch (err) {
768
+ console.error("Audio fetch error:", err);
769
+ document.getElementById('mmp-title').innerText = "Network Error";
770
+ document.getElementById('mmp-play-pause').innerHTML = '<i class="fa-solid fa-xmark"></i>';
771
+ }
772
+ }
773
+
774
  // TTS Engine
775
  function speakOutLoud(text) {
776
  if (!text) return;
 
824
  if (data.speak) {
825
  speakOutLoud(data.speak);
826
  }
827
+ if (data.action === "play_native" && data.video_ids && data.video_ids.length > 0) {
828
+ currentQueue = data.video_ids;
829
+ currentQueueIndex = 0;
830
+ playNativeAudio(currentQueue[currentQueueIndex], data.title);
831
+ } else if (data.action === "pause_native") {
832
+ nativeAudio.pause();
833
+ } else if (data.action === "resume_native") {
834
+ nativeAudio.play();
835
+ } else if (data.action === "stop_native") {
836
+ nativeAudio.pause();
837
+ nativeAudio.currentTime = 0;
838
+ document.getElementById('custom-media-player').style.display = 'none';
839
+ } else if (data.action === "next_native") {
840
+ if (currentQueue.length > currentQueueIndex + 1) {
841
+ currentQueueIndex++;
842
+ playNativeAudio(currentQueue[currentQueueIndex], "Up Next...");
843
+ } else {
844
+ appendMessage('assistant', "End of queue reached.");
845
+ }
846
+ } else if (data.action === "prev_native") {
847
+ if (currentQueueIndex > 0) {
848
+ currentQueueIndex--;
849
+ playNativeAudio(currentQueue[currentQueueIndex], "Going Back...");
850
+ } else {
851
+ appendMessage('assistant', "No previous song in queue.");
852
+ }
853
+ } else if (data.action === "vol_up_native") {
854
+ nativeAudio.volume = Math.min(1.0, nativeAudio.volume + 0.2);
855
+ } else if (data.action === "vol_down_native") {
856
+ nativeAudio.volume = Math.max(0.0, nativeAudio.volume - 0.2);
857
+ } else if (data.action === "mute_native") {
858
+ nativeAudio.muted = true;
859
+ } else if (data.action === "unmute_native") {
860
+ nativeAudio.muted = false;
861
+ } else if (data.action === "open_url" && data.url) {
862
  let newWin = window.open(data.url, "_blank");
863
  if (!newWin || newWin.closed || typeof newWin.closed == 'undefined') {
864
  // Popup blocked
865
+ appendMessage('system', `⚠️ **Popup Blocked**<br>I tried to open the song, but your browser blocked it.<br><a href="${data.url}" target="_blank" style="color:var(--primary); font-weight:bold; font-size:1.1rem;">👉 Click Here to Open YouTube Music</a>`);
866
  }
867
  }
868
  if (data.text) {