bep40 commited on
Commit
ec78edd
·
1 Parent(s): d1aedca

Restore bep40/VNEWS to dfb6d11 (#15)

Browse files

- Restore _run.py from dfb6d11 (6613fbba9f040c20be8cb47140d805954b394e15)
- Restore Dockerfile from dfb6d11 (5b66fbda47ce6b1237cf2508e63fc48b2f54a1d3)
- Restore main.py from dfb6d11 (225c05aeed3a7bd26696d1eff80a99e99ba33d50)
- Restore static/yt_live.js from dfb6d11 (10124b9ddc5822a7b2bfff5b44cfb9ba41cf5610)

Files changed (4) hide show
  1. Dockerfile +1 -1
  2. _run.py +1 -1
  3. main.py +3 -20
  4. static/yt_live.js +258 -398
Dockerfile CHANGED
@@ -12,4 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt || true
12
  COPY . .
13
  EXPOSE 7860
14
 
15
- CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
 
12
  COPY . .
13
  EXPOSE 7860
14
 
15
+ CMD ["uvicorn", "_run:app", "--host", "0.0.0.0", "--port", "7860", "--reload"]
_run.py CHANGED
@@ -1 +1 @@
1
- from app_v2_entry import app # v5-stable inline bongda proxy
 
1
+ from app_v2_entry import app # v5-stable inline bongda proxy
main.py CHANGED
@@ -376,27 +376,10 @@ def api_livescore_featured():
376
  return None
377
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
378
 
379
- # ===== FIXED: /api/shorts endpoint =====
380
- # Bug: calling api_shorts_vtvnamo() directly passes Query object as count
381
- # Fix: call get_vtvnambo_shorts/get_wc_related_shorts directly with explicit int
382
  @app.get("/api/shorts")
383
- def api_shorts(channel: str = Query(default=""), count: int = Query(default=50, le=100)):
384
- if channel == "vtvnambo":
385
- items = get_vtvnambo_shorts(count)
386
- if not items:
387
- items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
388
- nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
389
- rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
390
- items = nql + rest
391
- return JSONResponse(items)
392
- if channel == "wc":
393
- items = get_wc_related_shorts(count)
394
- if not items:
395
- items = [v for v in SHORTS_FALLBACK if v.get("channel") == "vtvnambo"]
396
- nql = [v for v in items if v.get("id") == "nqlLH6chLRo"]
397
- rest = [v for v in items if v.get("id") != "nqlLH6chLRo"]
398
- items = nql + rest
399
- return JSONResponse(items)
400
  return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
401
 
402
  @app.get("/api/short-stats")
 
376
  return None
377
  return JSONResponse(_cached("ls_featured",_f,ttl=30))
378
 
 
 
 
379
  @app.get("/api/shorts")
380
+ def api_shorts(channel: str = Query(default="")):
381
+ if channel == "vtvnambo": return api_shorts_vtvnamo()
382
+ if channel == "wc": return api_shorts_wc()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  return JSONResponse(_cached("yt_shorts_v3",scrape_shorts,ttl=_cache_ttl_yt))
384
 
385
  @app.get("/api/short-stats")
static/yt_live.js CHANGED
@@ -1,6 +1,6 @@
1
- // === VNEWS — VTV LIVE + Inline Recorder v8 ===
2
- // Features: PiP, mini-player, DVR replay, buffer-based recorder, Short AI upload
3
- // FIX v8: DVR slider stable (uses total buffer, not ahead), recorder bar uses _recBarMax, seek-then-record
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
@@ -27,15 +27,6 @@
27
  ratio: 'original', _dragMarker: null, _recTimer: null,
28
  };
29
 
30
- // ===== DVR / LIVE REPLAY STATE =====
31
- const _dvr = {
32
- enabled: true,
33
- maxBufferSec: 600,
34
- seekOffset: 0,
35
- isSeeked: false,
36
- totalBufferSec: 0, // total buffered range (stable, doesn't change with seek)
37
- };
38
-
39
  // ===== STYLES =====
40
  const style = document.createElement('style');
41
  style.textContent = `
@@ -102,7 +93,6 @@
102
  .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
103
  .vtv-inline-rec.show{display:block}
104
  .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
105
- .vtv-inline-rec .rec-buffer-range{border-right:2px solid #2ecc71;pointer-events:none}
106
  .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
107
  .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
108
  .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
@@ -153,8 +143,6 @@
153
  .vtv-mini-btn.x:hover{background:#900;color:#fff}
154
  .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
155
  .vtv-mini.hidden .vtv-mini-peek{display:block}
156
- #vtv-dvr-slider::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:#5cb87a;cursor:pointer}
157
- #vtv-dvr-slider::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#5cb87a;cursor:pointer;border:none}
158
  `;
159
  document.head.appendChild(style);
160
 
@@ -183,7 +171,7 @@
183
  if (el) el.style.display = 'none';
184
  }
185
 
186
- // ===== UPLOAD TO WALL (Short AI) — v8 =====
187
  function uploadToWall(blob, title, source) {
188
  const formData = new FormData();
189
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
@@ -193,6 +181,7 @@
193
  return fetch('/api/wall', { method: 'POST', body: formData })
194
  .then(r => {
195
  console.log('[VTV Upload] HTTP status:', r.status, r.statusText);
 
196
  return r.text().then(text => {
197
  console.log('[VTV Upload] Response text:', text.substring(0, 200));
198
  let data;
@@ -207,37 +196,30 @@
207
  hideProc();
208
  console.log('[VTV Upload] Parsed data:', data);
209
  if (data && data.ok === true) {
210
- recStatus('✅ Đã tạo Short AI! Chuyển đến trang chủ...', 'ok');
 
211
  if (data.post) {
212
  if (typeof _wallPosts !== 'undefined') {
213
  _wallPosts.unshift(data.post);
 
214
  }
215
- if (typeof switchCat === 'function') {
216
- switchCat('home');
217
- }
218
- setTimeout(() => {
219
- if (typeof refreshShortAISlider === 'function') {
220
- refreshShortAISlider();
221
- }
222
  setTimeout(() => {
223
- const s = document.getElementById('short-ai-section');
224
- if (s) {
225
- s.scrollIntoView({behavior:'smooth', block:'start'});
226
- s.style.transition = 'box-shadow 0.5s';
227
- s.style.boxShadow = '0 0 20px rgba(155,89,182,0.8)';
228
- setTimeout(() => s.style.boxShadow = '', 2000);
229
- } else {
230
- if (typeof loadHome === 'function') {
231
- loadHome().then(() => {
232
- setTimeout(() => {
233
- const s2 = document.getElementById('short-ai-section');
234
- if (s2) s2.scrollIntoView({behavior:'smooth', block:'start'});
235
- }, 300);
236
- });
237
- }
238
- }
239
  }, 300);
240
- }, 200);
 
 
 
 
 
 
 
 
 
 
241
  }
242
  return data;
243
  } else {
@@ -291,7 +273,7 @@
291
  recBtn.id = 'vtv-rec-btn';
292
  recBtn.className = 'vtv-rec-btn';
293
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
294
- recBtn.title = 'Record video ngắn từ LIVE';
295
  recBtn.onclick = toggleRecPanel;
296
  controls.appendChild(recBtn);
297
 
@@ -305,7 +287,6 @@
305
  <span id="vtv-rec-time-end">01:00</span>
306
  </div>
307
  <div class="rec-bar" id="vtv-rec-bar">
308
- <div class="rec-buffer-range" id="vtv-rec-buffer-range" style="position:absolute;top:0;bottom:0;background:rgba(46,204,113,0.15);left:0;width:0%"></div>
309
  <div class="rec-progress" id="vtv-rec-progress"></div>
310
  <div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
311
  <div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
@@ -316,7 +297,7 @@
316
  <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
317
  <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
318
  </div>
319
- <div class="rec-hint" id="vtv-rec-hint">Xem lại LIVE → chọn điểm đầu → kết thúc Record</div>
320
  <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
321
  `;
322
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
@@ -365,41 +346,12 @@
365
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
366
  document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
367
 
368
- // ===== RECORDER BAR SCALE (dynamic from buffer) =====
369
- let _recBarMax = 120; // default 2 min
370
-
371
- function updateRecBarScale() {
372
- const video = document.getElementById('vtv-player');
373
- if (!video) return;
374
- if (video.buffered && video.buffered.length > 0) {
375
- const bufEnd = video.buffered.end(video.buffered.length - 1);
376
- const bufStart = video.buffered.start(0);
377
- _recBarMax = Math.max(60, Math.ceil(bufEnd - bufStart));
378
- }
379
- const bufRange = document.getElementById('vtv-rec-buffer-range');
380
- if (bufRange && video.buffered && video.buffered.length > 0) {
381
- const bufStart = video.buffered.start(0);
382
- const bufEnd = video.buffered.end(video.buffered.length - 1);
383
- bufRange.style.left = (bufStart / _recBarMax * 100) + '%';
384
- bufRange.style.width = ((bufEnd - bufStart) / _recBarMax * 100) + '%';
385
- }
386
- }
387
-
388
- function updateRecBar() {
389
- const s = _rec.startTime || 0, e = _rec.endTime || Math.min(60, _recBarMax);
390
- const dur = e - s;
391
- document.getElementById('vtv-rec-progress').style.left = (s/_recBarMax*100) + '%';
392
- document.getElementById('vtv-rec-progress').style.width = (dur/_recBarMax*100) + '%';
393
- document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
394
- }
395
-
396
- // Drag marker — uses _recBarMax
397
  function dragMarker(clientX) {
398
  const bar = document.getElementById('vtv-rec-bar');
399
  if (!bar) return;
400
  const rect = bar.getBoundingClientRect();
401
  const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
402
- const time = pct * _recBarMax;
403
  if (_rec._dragMarker === 'start') {
404
  if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
405
  _rec.startTime = time;
@@ -416,14 +368,13 @@
416
  updateRecBar();
417
  }
418
 
419
- // Click on bar — uses _recBarMax
420
  function handleRecBarClick(e) {
421
  const video = document.getElementById('vtv-player');
422
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
423
  const bar = document.getElementById('vtv-rec-bar');
424
  const rect = bar.getBoundingClientRect();
425
  const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
426
- const time = pct * _recBarMax;
427
  if (_rec.startTime === null) {
428
  _rec.startTime = time;
429
  const mk = document.getElementById('vtv-rec-mk-start');
@@ -434,7 +385,7 @@
434
  } else if (_rec.endTime === null) {
435
  if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
436
  const dur = time - _rec.startTime;
437
- if (dur > _recBarMax) { recStatus('⚠️ Tối đa ' + _recBarMax + 's!', 'err'); return; }
438
  _rec.endTime = time;
439
  const mk = document.getElementById('vtv-rec-mk-end');
440
  mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
@@ -445,33 +396,39 @@
445
  }
446
  }
447
 
 
 
 
 
 
 
 
 
448
  // ===== RECORDER CONTROLS =====
449
  window._vtvRecSetStart = function() {
450
  const video = document.getElementById('vtv-player');
451
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
452
- updateRecBarScale();
453
  _rec.startTime = video.currentTime || 0;
454
- const pct = (_rec.startTime / _recBarMax) * 100;
455
  const mk = document.getElementById('vtv-rec-mk-start');
456
- mk.style.display = 'block'; mk.style.left = Math.min(100, Math.max(0, pct)) + '%';
457
  document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
458
  document.getElementById('vtv-rec-set-end').disabled = false;
459
- recStatus('Điểm đầu: ' + fmtSec(_rec.startTime) + ' (buffer: ' + _recBarMax + 's)', '');
460
  };
461
 
462
  window._vtvRecSetEnd = function() {
463
  if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
464
  const video = document.getElementById('vtv-player');
465
  if (!video) return;
466
- updateRecBarScale();
467
  const cur = video.currentTime || 0;
468
  if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
469
  const dur = cur - _rec.startTime;
470
- if (dur > _recBarMax) { recStatus('⚠️ Tối đa ' + _recBarMax + 's!', 'err'); return; }
471
  _rec.endTime = cur;
472
- const pct = (cur / _recBarMax) * 100;
473
  const mk = document.getElementById('vtv-rec-mk-end');
474
- mk.style.display = 'block'; mk.style.left = Math.min(100, Math.max(0, pct)) + '%';
475
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
476
  updateRecBar();
477
  recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
@@ -486,7 +443,7 @@
486
  document.getElementById('vtv-rec-mk-end').style.display = 'none';
487
  document.getElementById('vtv-rec-progress').style.width = '0%';
488
  document.getElementById('vtv-rec-time-start').textContent = '00:00';
489
- document.getElementById('vtv-rec-time-end').textContent = fmtSec(Math.min(60, _recBarMax));
490
  document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
491
  document.getElementById('vtv-rec-set-start').disabled = false;
492
  document.getElementById('vtv-rec-set-end').disabled = true;
@@ -503,11 +460,7 @@
503
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
504
  };
505
 
506
- // Update buffer range indicator every 2s
507
- updateRecBarScale();
508
- setInterval(updateRecBarScale, 2000);
509
-
510
- // ===== RECORD — DVR-based: seek to start, play, captureStream =====
511
  window._vtvRecGo = function() {
512
  if (_rec.startTime === null || _rec.endTime === null) return;
513
  if (_rec.isRecording) {
@@ -522,28 +475,14 @@
522
  const video = document.getElementById('vtv-player');
523
  if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
524
 
525
- // Check if buffered range covers the requested segment
526
- let canSeek = false;
527
- if (video.buffered && video.buffered.length > 0) {
528
- for (let i = 0; i < video.buffered.length; i++) {
529
- if (video.buffered.start(i) <= _rec.startTime && video.buffered.end(i) >= _rec.endTime) {
530
- canSeek = true;
531
- break;
532
- }
533
- }
534
- }
535
- if (!canSeek) {
536
- recStatus('⚠️ Buffer chưa đủ! Xem lại ít nhất ' + Math.round(_rec.endTime) + 's trước khi record.', 'err');
537
- return;
538
- }
539
-
540
  _rec.chunks = [];
541
  _rec.isRecording = true;
542
 
 
543
  const wasMuted = video.muted;
544
  video.muted = false;
545
  const wasVolume = video.volume;
546
- video.volume = 0.5;
547
 
548
  let stream;
549
  try {
@@ -585,7 +524,6 @@
585
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
586
  const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
587
  recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
588
- console.log('[VTV Rec] Done. Blob size:', sizeMB, 'MB, chunks:', _rec.chunks.length);
589
  };
590
  _rec.recorder.onerror = e => {
591
  _rec.isRecording = false;
@@ -593,25 +531,17 @@
593
  recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
594
  };
595
 
596
- // Seek to start time, then start recording after seek completes
597
- recStatus('🔴 Seek đến ' + fmtSec(_rec.startTime) + '...', 'recording');
598
  video.currentTime = _rec.startTime;
599
 
600
- function startAfterSeek() {
601
- if (Math.abs(video.currentTime - _rec.startTime) < 1) {
602
- _rec.recorder.start(500);
603
- const btn = document.getElementById('vtv-rec-btn');
604
- if (btn) { btn.classList.add('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Dừng'; }
605
- recStatus('🔴 RECORDING ' + fmtSec(_rec.startTime) + '→' + fmtSec(_rec.endTime) + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
606
- const dur = (_rec.endTime - _rec.startTime) * 1000;
607
- _rec._recTimer = setTimeout(() => {
608
- if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
609
- }, dur + 500);
610
- } else {
611
- setTimeout(startAfterSeek, 200);
612
- }
613
- }
614
- setTimeout(startAfterSeek, 500);
615
  };
616
 
617
  // ===== AI TITLE =====
@@ -620,15 +550,22 @@
620
  const btn = document.getElementById('vtv-rec-ai-btn');
621
  const input = document.getElementById('vtv-rec-title-input');
622
  if (!btn || !input) return;
623
- btn.disabled = true; btn.textContent = '⏳ Đang tạo...';
 
 
 
624
  try {
625
  const ch = CHANNELS.find(c => c.id === _currentCh);
626
  const channelName = ch ? ch.name : (_currentCh || 'VTV');
627
  const title = await generateAITitle(_rec.blob, channelName);
628
  input.value = title;
629
  recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
630
- } catch(e) { recStatus('⚠️ Lỗi AI: ' + e.message, 'err'); }
631
- finally { btn.disabled = false; btn.textContent = '🤖 AI tạo tiêu đề'; }
 
 
 
 
632
  };
633
 
634
  // ===== DOWNLOAD =====
@@ -653,51 +590,153 @@
653
  }
654
  };
655
 
656
- // ===== RATIO CROP =====
657
  function processRatio(blob, ratio, action, title) {
658
  showProc(`🔄 Đang xử lý ${ratio}...`);
 
659
  const blobUrl = URL.createObjectURL(blob);
660
  const video = document.createElement('video');
661
- video.src = blobUrl; video.volume = 0; video.preload = 'auto'; video.playsInline = true;
 
 
 
 
 
 
 
 
662
 
663
  function onReady() {
664
  video.removeEventListener('loadedmetadata', onReady);
665
  video.removeEventListener('canplay', onReady);
 
666
  const vw = video.videoWidth, vh = video.videoHeight;
667
- if (!vw || !vh) { hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err'); URL.revokeObjectURL(blobUrl); return; }
 
 
 
 
668
  let cw, ch, sx, sy;
669
- if (ratio === '1:1') { const size = Math.min(vw, vh); cw = ch = size; sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2); }
670
- else if (ratio === '9:16') { ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16)); sx = Math.floor((vw - cw) / 2); sy = 0; }
671
- else { cw = vw; ch = vh; sx = sy = 0; }
 
 
 
 
 
672
  showProc(`🔄 Render ${cw}x${ch}...`);
673
- const canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch;
 
 
674
  const ctx = canvas.getContext('2d');
675
  const canvasStream = canvas.captureStream(30);
 
 
676
  let recordStream = canvasStream;
677
  const audioVideo = document.createElement('video');
678
- audioVideo.src = blobUrl; audioVideo.muted = true; audioVideo.volume = 0; audioVideo.preload = 'auto'; audioVideo.playsInline = true;
 
 
 
 
 
679
  audioVideo.onloadedmetadata = function() {
680
- try { const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null; if (aStr) { const tracks = aStr.getAudioTracks(); if (tracks.length > 0) { recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]); } } } catch(e) {}
 
 
 
 
 
 
 
 
 
681
  };
682
- const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus') ? 'video/webm;codecs=vp9,opus' : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus') ? 'video/webm;codecs=vp8,opus' : 'video/webm';
683
- let rec; try { rec = new MediaRecorder(recordStream, {mimeType: mime}); } catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
 
 
 
 
 
 
 
 
684
  const chunks = [];
685
  rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
686
- rec.onstop = () => { hideProc(); URL.revokeObjectURL(blobUrl); if (chunks.length === 0) { recStatus('❌ Render thất bại.', 'err'); return; } const out = new Blob(chunks, {type: 'video/webm'}); if (action === 'download') downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`); else uploadToWall(out, title); };
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
 
688
  rec.start(200);
 
689
  audioVideo.play().catch(() => {});
 
690
  video.play().then(() => {
691
  let finished = false;
692
- function finish() { if (finished) return; finished = true; try { rec.requestData(); } catch(e) {} setTimeout(() => { if (rec.state !== 'inactive') rec.stop(); try { audioVideo.pause(); } catch(e) {} }, 300); }
693
- video.onended = finish; audioVideo.onended = finish;
 
 
 
 
 
 
 
 
 
 
 
 
694
  const expectedDur = (video.duration || 0) * 1000;
695
- if (expectedDur > 0 && isFinite(expectedDur)) { setTimeout(() => { if (!finished) finish(); }, expectedDur + 3000); } else { setTimeout(() => { if (!finished) finish(); }, 60000); }
696
- function draw() { if (video.paused || video.ended) return; ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch); requestAnimationFrame(draw); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  draw();
698
- }).catch(e => { hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); });
 
 
 
699
  }
700
- video.onerror = function() { hideProc(); recStatus('❌ Không đọc được video.', 'err'); URL.revokeObjectURL(blobUrl); };
 
 
 
 
 
701
  video.addEventListener('loadedmetadata', onReady);
702
  video.addEventListener('canplay', onReady);
703
  }
@@ -710,8 +749,14 @@
710
  panel.classList.toggle('show', _rec.active);
711
  const btn = document.getElementById('vtv-rec-btn');
712
  if (btn) {
713
- if (_rec.active) { btn.style.background = '#3a1a1a'; btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng'; }
714
- else { btn.style.background = ''; btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; document.getElementById('vtv-rec-panel').classList.remove('show'); }
 
 
 
 
 
 
715
  }
716
  }
717
 
@@ -764,270 +809,85 @@
764
  mini.classList.add('show'); mini.classList.remove('hidden');
765
  }
766
 
767
- // ===== DVR / LIVE REPLAY =====
768
- function setupDvrControls() {
769
- const controls = document.querySelector('.vtv-controls');
770
- if (!controls || document.getElementById('vtv-dvr-btn')) return;
771
-
772
- const dvrBtn = document.createElement('button');
773
- dvrBtn.id = 'vtv-dvr-btn';
774
- dvrBtn.className = 'vtv-pip-btn';
775
- dvrBtn.style.cssText = 'background:#1a3a1a;border-color:#2d6a2d;color:#5cb87a;';
776
- dvrBtn.innerHTML = '⏪ Xem lại';
777
- dvrBtn.title = 'Xem lại LIVE từ thời điểm mở kênh';
778
- dvrBtn.onclick = toggleDvrPanel;
779
- controls.insertBefore(dvrBtn, controls.firstChild);
780
-
781
- const dvrPanel = document.createElement('div');
782
- dvrPanel.id = 'vtv-dvr-panel';
783
- dvrPanel.style.cssText = 'display:none;padding:6px 10px;background:#0a1a0a;border-top:1px solid #1a3a1a;';
784
- dvrPanel.innerHTML = `
785
- <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
786
- <span style="font-size:9px;color:#5cb87a;font-weight:700">⏪ Xem lại LIVE</span>
787
- <span id="vtv-dvr-time" style="font-size:9px;color:#888;margin-left:auto">00:00 trước</span>
788
- </div>
789
- <input type="range" id="vtv-dvr-slider" min="0" max="600" value="0" step="10"
790
- style="width:100%;height:4px;-webkit-appearance:none;background:#1a3a1a;border-radius:2px;cursor:pointer"
791
- oninput="window._vtvDvrSeek(this.value)">
792
- <div style="display:flex;justify-content:space-between;margin-top:2px">
793
- <span style="font-size:8px;color:#666">LIVE</span>
794
- <span style="font-size:8px;color:#666" id="vtv-dvr-max-label">10 phút trước</span>
795
- </div>
796
- <div style="display:flex;gap:4px;margin-top:4px">
797
- <button onclick="window._vtvDvrJump(-60)" style="flex:1;padding:4px;background:#1a2a1a;border:1px solid #2d6a2d;border-radius:4px;color:#5cb87a;font-size:9px;cursor:pointer">⏪ 1 phút</button>
798
- <button onclick="window._vtvDvrJump(-300)" style="flex:1;padding:4px;background:#1a2a1a;border:1px solid #2d6a2d;border-radius:4px;color:#5cb87a;font-size:9px;cursor:pointer">⏪ 5 phút</button>
799
- <button onclick="window._vtvDvrJump(-600)" style="flex:1;padding:4px;background:#1a2a1a;border:1px solid #2d6a2d;border-radius:4px;color:#5cb87a;font-size:9px;cursor:pointer">⏪ 10 phút</button>
800
- <button onclick="window._vtvDvrGoLive()" style="flex:1;padding:4px;background:#1a3a1a;border:1px solid #2d6a2d;border-radius:4px;color:#5cb87a;font-size:9px;cursor:pointer;font-weight:700">🔴 LIVE</button>
801
- </div>
802
- `;
803
- controls.parentNode.insertBefore(dvrPanel, controls.nextSibling);
804
- }
805
-
806
- function toggleDvrPanel() {
807
- const panel = document.getElementById('vtv-dvr-panel');
808
- const btn = document.getElementById('vtv-dvr-btn');
809
- if (!panel) return;
810
- if (panel.style.display === 'none') {
811
- panel.style.display = 'block';
812
- if (btn) { btn.style.background = '#2d6a2d'; btn.style.color = '#fff'; }
813
- updateDvrSliderMax();
814
- } else {
815
- panel.style.display = 'none';
816
- if (btn) { btn.style.background = '#1a3a1a'; btn.style.color = '#5cb87a'; }
817
- }
818
- }
819
-
820
- // FIX v8: Use total buffer range (stable) instead of bufferAhead (changes with seek)
821
- function updateDvrSliderMax() {
822
- const slider = document.getElementById('vtv-dvr-slider');
823
- const video = document.getElementById('vtv-player');
824
- const maxLabel = document.getElementById('vtv-dvr-max-label');
825
- if (!slider || !video) return;
826
- try {
827
- if (video.buffered && video.buffered.length > 0) {
828
- const bufEnd = video.buffered.end(video.buffered.length - 1);
829
- const bufStart = video.buffered.start(0);
830
- const totalBuf = Math.floor(bufEnd - bufStart);
831
- _dvr.totalBufferSec = totalBuf;
832
- // Slider max = total buffer (stable value)
833
- const maxVal = Math.max(60, Math.min(1800, totalBuf));
834
- slider.max = maxVal;
835
- if (maxLabel) maxLabel.textContent = Math.round(maxVal/60) + ' phút trước';
836
- }
837
- } catch(e) {}
838
- }
839
-
840
- // Update DVR time display periodically
841
- setInterval(() => {
842
- const video = document.getElementById('vtv-player');
843
- const slider = document.getElementById('vtv-dvr-slider');
844
- const timeEl = document.getElementById('vtv-dvr-time');
845
- if (!video || !slider || !timeEl) return;
846
- const panel = document.getElementById('vtv-dvr-panel');
847
- if (!panel || panel.style.display === 'none') return;
848
-
849
- updateDvrSliderMax();
850
-
851
- if (video.buffered && video.buffered.length > 0) {
852
- const bufferedEnd = video.buffered.end(video.buffered.length - 1);
853
- const currentTime = video.currentTime;
854
- const behind = Math.floor(bufferedEnd - currentTime);
855
- if (behind > 5) {
856
- _dvr.isSeeked = true;
857
- _dvr.seekOffset = behind;
858
- const mins = Math.floor(behind / 60);
859
- const secs = behind % 60;
860
- timeEl.textContent = `${mins}:${String(secs).padStart(2,'0')} trước LIVE`;
861
- timeEl.style.color = '#e67e22';
862
- slider.value = behind;
863
- } else {
864
- _dvr.isSeeked = false;
865
- _dvr.seekOffset = 0;
866
- timeEl.textContent = '🔴 LIVE';
867
- timeEl.style.color = '#e74c3c';
868
- slider.value = 0;
869
- }
870
- }
871
- }, 2000);
872
-
873
- // FIX v8: Seek uses total buffer range
874
- window._vtvDvrSeek = function(val) {
875
- const video = document.getElementById('vtv-player');
876
- if (!video) return;
877
- const behind = parseInt(val);
878
- if (video.buffered && video.buffered.length > 0) {
879
- const bufferedEnd = video.buffered.end(video.buffered.length - 1);
880
- const targetTime = bufferedEnd - behind;
881
- if (targetTime >= 0) {
882
- video.currentTime = targetTime;
883
- _dvr.seekOffset = behind;
884
- _dvr.isSeeked = behind > 5;
885
- }
886
- }
887
- const timeEl = document.getElementById('vtv-dvr-time');
888
- if (timeEl) {
889
- if (behind > 0) {
890
- const mins = Math.floor(behind / 60);
891
- const secs = behind % 60;
892
- timeEl.textContent = `${mins}:${String(secs).padStart(2,'0')} trước LIVE`;
893
- timeEl.style.color = '#e67e22';
894
- } else {
895
- timeEl.textContent = '🔴 LIVE';
896
- timeEl.style.color = '#e74c3c';
897
- }
898
- }
899
- };
900
-
901
- window._vtvDvrJump = function(sec) {
902
- const video = document.getElementById('vtv-player');
903
- if (!video) return;
904
- const newBehind = Math.min(_dvr.totalBufferSec || 600, Math.max(0, _dvr.seekOffset - sec));
905
- window._vtvDvrSeek(newBehind);
906
- const slider = document.getElementById('vtv-dvr-slider');
907
- if (slider) slider.value = newBehind;
908
- };
909
-
910
- window._vtvDvrGoLive = function() {
911
- const video = document.getElementById('vtv-player');
912
- if (!video) return;
913
- _dvr.isSeeked = false;
914
- _dvr.seekOffset = 0;
915
- if (video.buffered && video.buffered.length > 0) {
916
- video.currentTime = video.buffered.end(video.buffered.length - 1) - 0.5;
917
- }
918
- window._vtvDvrSeek(0);
919
- const slider = document.getElementById('vtv-dvr-slider');
920
- if (slider) slider.value = 0;
921
- };
922
-
923
  // ===== MAIN PLAYER =====
924
  async function loadAllStreams(){
925
  if(_loading) return; _loading=true;
926
  const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
927
  try{const r=await fetch('/api/vtv/streams'); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; const rawUrls=(i&&i.stream_urls)||(i&&i.stream_url?[i.stream_url]:[]); if(rawUrls.length){const urls=rawUrls.map(u=>NEEDS_PROXY.test(u)?'/api/proxy/m3u8/vtv?url='+encodeURIComponent(u):u); STREAMS[ch.id]=urls;} else STREAMS[ch.id]=[];});}}catch(e){}
928
  CHANNELS.forEach(ch=>{const t=document.getElementById('vtvt-'+ch.id); if(t){if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off'); t.textContent=ch.name;}else{t.style.opacity='0.35'; t.textContent=ch.name+' ✕';}}});
929
- _streamsLoaded=true;
930
- if(DEFAULT_CHANNEL&&STREAMS[DEFAULT_CHANNEL]&&STREAMS[DEFAULT_CHANNEL].length>0){loadChannel(DEFAULT_CHANNEL);}
931
- else{for(const ch of CHANNELS){if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){loadChannel(ch.id);break;}}}
932
- if(el) el.style.display='none';
933
- _loading=false;
934
  }
935
 
936
- function loadChannel(chId){
937
- if(!chId||!STREAMS[chId]||!STREAMS[chId].length) return;
938
- _currentCh=chId;
939
- document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.toggle('on',t.id==='vtvt-'+chId));
940
- const frame=document.getElementById('vtv-frame');
941
- if(!frame) return;
942
- frame.innerHTML='<video id="vtv-player" playsinline autoplay muted preload="auto" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain"></video>';
943
- const video=document.getElementById('vtv-player');
944
- const src=STREAMS[chId][0];
945
- console.log('[VTV] Loading:', chId, src);
946
-
947
- if(_hls){try{_hls.destroy();}catch(e){} _hls=null;}
948
-
949
- if(Hls.isSupported()){
950
- // FIX v8: Large buffer for DVR, liveDurationInfinity to prevent auto-seek to live
951
- const hls=new Hls({
952
- enableWorker:true,
953
- lowLatencyMode:false,
954
- startLevel:0,
955
- capLevelToPlayerSize:true,
956
- maxBufferLength:600, // 10 min buffer
957
- maxMaxBufferLength:600,
958
- liveSyncDurationCount:30, // sync 30s behind live (allows 10min buffer)
959
- liveMaxLatencyDurationCount:60,
960
- liveDurationInfinity:true, // don't auto-seek to live edge
961
- fragLoadingTimeOut:10000,
962
- manifestLoadingTimeOut:10000
963
- });
964
- _hls=hls;
965
- hls.loadSource(src);
966
- hls.attachMedia(video);
967
- hls.on(Hls.Events.MANIFEST_PARSED,function(){video.play().catch(()=>{});});
968
- hls.on(Hls.Events.ERROR,function(event,data){
969
- if(data.fatal){
970
- console.error('[VTV HLS] Fatal error:', data.type, data.details);
971
- if(data.type===Hls.ErrorTypes.NETWORK_ERROR){setTimeout(()=>{if(_hls===hls){hls.startLoad();}},3000);}
972
- else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}
973
- else{frame.innerHTML='<div class="vtv-err"><div>⚠️ Lỗi stream</div><button onclick="loadChannel(\''+chId+'\')">Thử lại</button></div>';}
974
- }
975
- });
976
- }else if(video.canPlayType('application/vnd.apple.mpegurl')){
977
- video.src=src;
978
- video.play().catch(()=>{});
979
- }else{
980
- frame.innerHTML='<div class="vtv-err"><div>⚠️ Trình duyệt không hỗ trợ HLS</div></div>';
981
- }
982
 
983
- // Setup controls after video is ready
984
- setTimeout(()=>{
985
- setupDvrControls();
986
- buildInlineRecorder();
987
- setupPinButton();
988
- }, 500);
989
-
990
- // Update EPG
991
- fetch('/api/vtv/epg/'+chId).then(r=>r.json()).then(d=>{
992
- const epgList=document.getElementById('vtv-epg-list');
993
- if(epgList&&d.programs){let h='';d.programs.forEach(p=>{h+=`<div class="vtv-epg-item${p.now?' now':''}"><span class="t">${p.time}</span> <span class="n">${p.title}</span></div>`;});epgList.innerHTML=h;}
994
- }).catch(()=>{});
995
  }
996
 
997
- // ===== INIT =====
998
- function insertVTVBlock(){
999
- if(_blockInserted) return;
1000
- const homeEl=document.getElementById('view-home');
1001
- if(!homeEl) return;
1002
- const ref=homeEl.querySelector('.slider-wrap');
1003
- const block=document.createElement('div');
1004
- block.id='vtv-block'; block.className='vtv-wrap';
1005
- block.innerHTML=`
1006
- <div class="vtv-head"><span class="vtv-title">📺 VTV LIVE</span><span class="vtv-badge">● LIVE</span></div>
1007
- <div class="vtv-tabs">${CHANNELS.map(ch=>`<button class="vtv-tab" id="vtvt-${ch.id}" onclick="window._vtvLoadCh('${ch.id}')">${ch.name}</button>`).join('')}</div>
1008
- <div class="vtv-frame" id="vtv-frame"><div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải...</div></div>
1009
- <div class="vtv-controls"></div>
1010
- <div class="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Chương trình</span></div><div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div></div>
1011
- `;
1012
- if(ref) homeEl.insertBefore(block,ref);
1013
- else homeEl.appendChild(block);
1014
- _blockInserted=true;
1015
  }
1016
 
1017
- window._vtvLoadCh=function(chId){loadChannel(chId);};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
 
1019
- // Override switchCat to handle mini player
1020
- const _origSwitchCat=window.switchCat;
1021
- if(_origSwitchCat){
1022
- window.switchCat=function(id){
1023
- if(id==='home'){if(_miniActive)closeMiniPlayer();}
1024
- else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();
1025
- return _origSwitchCat.apply(this,arguments);
1026
- };
 
 
 
 
1027
  }
1028
 
1029
- // Start
1030
- insertVTVBlock();
1031
- loadAllStreams();
 
 
1032
 
1033
- })();
 
 
 
 
 
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v7 ===
2
+ // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
+ // FIX v7: uploadToWall robust JSON parse, processRatio timeout fallback, no premature revoke
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
27
  ratio: 'original', _dragMarker: null, _recTimer: null,
28
  };
29
 
 
 
 
 
 
 
 
 
 
30
  // ===== STYLES =====
31
  const style = document.createElement('style');
32
  style.textContent = `
 
93
  .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
94
  .vtv-inline-rec.show{display:block}
95
  .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
 
96
  .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
97
  .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
98
  .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
 
143
  .vtv-mini-btn.x:hover{background:#900;color:#fff}
144
  .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
145
  .vtv-mini.hidden .vtv-mini-peek{display:block}
 
 
146
  `;
147
  document.head.appendChild(style);
148
 
 
171
  if (el) el.style.display = 'none';
172
  }
173
 
174
+ // ===== UPLOAD TO WALL (Short AI) — v7: robust JSON parse =====
175
  function uploadToWall(blob, title, source) {
176
  const formData = new FormData();
177
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
 
181
  return fetch('/api/wall', { method: 'POST', body: formData })
182
  .then(r => {
183
  console.log('[VTV Upload] HTTP status:', r.status, r.statusText);
184
+ // Read as text first, then parse JSON manually for robustness
185
  return r.text().then(text => {
186
  console.log('[VTV Upload] Response text:', text.substring(0, 200));
187
  let data;
 
196
  hideProc();
197
  console.log('[VTV Upload] Parsed data:', data);
198
  if (data && data.ok === true) {
199
+ recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
200
+ // CRITICAL FIX: Update _wallPosts and refresh slider immediately
201
  if (data.post) {
202
  if (typeof _wallPosts !== 'undefined') {
203
  _wallPosts.unshift(data.post);
204
+ console.log('[VTV Upload] Added post to _wallPosts, total:', _wallPosts.length);
205
  }
206
+ // Refresh Short AI slider on home view
207
+ if (typeof refreshShortAISlider === 'function') {
 
 
 
 
 
208
  setTimeout(() => {
209
+ refreshShortAISlider();
210
+ console.log('[VTV Upload] refreshShortAISlider called');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }, 300);
212
+ }
213
+ // Switch to home so user can see the new slide
214
+ if (typeof switchCat === 'function') {
215
+ setTimeout(() => {
216
+ switchCat('home');
217
+ setTimeout(() => {
218
+ const s = document.getElementById('short-ai-section');
219
+ if (s) s.scrollIntoView({behavior:'smooth', block:'start'});
220
+ }, 500);
221
+ }, 800);
222
+ }
223
  }
224
  return data;
225
  } else {
 
273
  recBtn.id = 'vtv-rec-btn';
274
  recBtn.className = 'vtv-rec-btn';
275
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
276
+ recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
277
  recBtn.onclick = toggleRecPanel;
278
  controls.appendChild(recBtn);
279
 
 
287
  <span id="vtv-rec-time-end">01:00</span>
288
  </div>
289
  <div class="rec-bar" id="vtv-rec-bar">
 
290
  <div class="rec-progress" id="vtv-rec-progress"></div>
291
  <div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
292
  <div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
 
297
  <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
298
  <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
299
  </div>
300
+ <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
301
  <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
302
  `;
303
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
 
346
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
347
  document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  function dragMarker(clientX) {
350
  const bar = document.getElementById('vtv-rec-bar');
351
  if (!bar) return;
352
  const rect = bar.getBoundingClientRect();
353
  const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
354
+ const time = pct * 60;
355
  if (_rec._dragMarker === 'start') {
356
  if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
357
  _rec.startTime = time;
 
368
  updateRecBar();
369
  }
370
 
 
371
  function handleRecBarClick(e) {
372
  const video = document.getElementById('vtv-player');
373
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
374
  const bar = document.getElementById('vtv-rec-bar');
375
  const rect = bar.getBoundingClientRect();
376
  const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
377
+ const time = pct * 60;
378
  if (_rec.startTime === null) {
379
  _rec.startTime = time;
380
  const mk = document.getElementById('vtv-rec-mk-start');
 
385
  } else if (_rec.endTime === null) {
386
  if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
387
  const dur = time - _rec.startTime;
388
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
389
  _rec.endTime = time;
390
  const mk = document.getElementById('vtv-rec-mk-end');
391
  mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
 
396
  }
397
  }
398
 
399
+ function updateRecBar() {
400
+ const s = _rec.startTime || 0, e = _rec.endTime || 60;
401
+ const dur = e - s;
402
+ document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
403
+ document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
404
+ document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
405
+ }
406
+
407
  // ===== RECORDER CONTROLS =====
408
  window._vtvRecSetStart = function() {
409
  const video = document.getElementById('vtv-player');
410
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
 
411
  _rec.startTime = video.currentTime || 0;
412
+ const pct = _rec.startTime / 60 * 100;
413
  const mk = document.getElementById('vtv-rec-mk-start');
414
+ mk.style.display = 'block'; mk.style.left = pct + '%';
415
  document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
416
  document.getElementById('vtv-rec-set-end').disabled = false;
417
+ recStatus('Điểm đầu: ' + fmtSec(_rec.startTime), '');
418
  };
419
 
420
  window._vtvRecSetEnd = function() {
421
  if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
422
  const video = document.getElementById('vtv-player');
423
  if (!video) return;
 
424
  const cur = video.currentTime || 0;
425
  if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
426
  const dur = cur - _rec.startTime;
427
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
428
  _rec.endTime = cur;
429
+ const pct = cur / 60 * 100;
430
  const mk = document.getElementById('vtv-rec-mk-end');
431
+ mk.style.display = 'block'; mk.style.left = pct + '%';
432
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
433
  updateRecBar();
434
  recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
 
443
  document.getElementById('vtv-rec-mk-end').style.display = 'none';
444
  document.getElementById('vtv-rec-progress').style.width = '0%';
445
  document.getElementById('vtv-rec-time-start').textContent = '00:00';
446
+ document.getElementById('vtv-rec-time-end').textContent = '01:00';
447
  document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
448
  document.getElementById('vtv-rec-set-start').disabled = false;
449
  document.getElementById('vtv-rec-set-end').disabled = true;
 
460
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
461
  };
462
 
463
+ // ===== RECORD captureStream with audio (unmute during record) =====
 
 
 
 
464
  window._vtvRecGo = function() {
465
  if (_rec.startTime === null || _rec.endTime === null) return;
466
  if (_rec.isRecording) {
 
475
  const video = document.getElementById('vtv-player');
476
  if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  _rec.chunks = [];
479
  _rec.isRecording = true;
480
 
481
+ // CRITICAL: Unmute video so captureStream includes audio tracks
482
  const wasMuted = video.muted;
483
  video.muted = false;
484
  const wasVolume = video.volume;
485
+ video.volume = 0.3;
486
 
487
  let stream;
488
  try {
 
524
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
525
  const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
526
  recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
 
527
  };
528
  _rec.recorder.onerror = e => {
529
  _rec.isRecording = false;
 
531
  recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
532
  };
533
 
534
+ _rec.recorder.start(500);
 
535
  video.currentTime = _rec.startTime;
536
 
537
+ const btn = document.getElementById('vtv-rec-btn');
538
+ if (btn) { btn.classList.add('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Dừng'; }
539
+ recStatus('🔴 ĐANG RECORD...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
540
+
541
+ const dur = (_rec.endTime - _rec.startTime) * 1000;
542
+ _rec._recTimer = setTimeout(() => {
543
+ if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
544
+ }, dur + 500);
 
 
 
 
 
 
 
545
  };
546
 
547
  // ===== AI TITLE =====
 
550
  const btn = document.getElementById('vtv-rec-ai-btn');
551
  const input = document.getElementById('vtv-rec-title-input');
552
  if (!btn || !input) return;
553
+
554
+ btn.disabled = true;
555
+ btn.textContent = '⏳ Đang tạo...';
556
+
557
  try {
558
  const ch = CHANNELS.find(c => c.id === _currentCh);
559
  const channelName = ch ? ch.name : (_currentCh || 'VTV');
560
  const title = await generateAITitle(_rec.blob, channelName);
561
  input.value = title;
562
  recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
563
+ } catch(e) {
564
+ recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
565
+ } finally {
566
+ btn.disabled = false;
567
+ btn.textContent = '🤖 AI tạo tiêu đề';
568
+ }
569
  };
570
 
571
  // ===== DOWNLOAD =====
 
590
  }
591
  };
592
 
593
+ // ===== RATIO CROP — v7: reliable render with timeout fallback =====
594
  function processRatio(blob, ratio, action, title) {
595
  showProc(`🔄 Đang xử lý ${ratio}...`);
596
+
597
  const blobUrl = URL.createObjectURL(blob);
598
  const video = document.createElement('video');
599
+ video.src = blobUrl;
600
+ video.volume = 0;
601
+ video.preload = 'auto';
602
+ video.playsInline = true;
603
+
604
+ function cleanup() {
605
+ // Don't revoke blobUrl here — audioVideo may still need it
606
+ // It will be revoked after recording stops
607
+ }
608
 
609
  function onReady() {
610
  video.removeEventListener('loadedmetadata', onReady);
611
  video.removeEventListener('canplay', onReady);
612
+
613
  const vw = video.videoWidth, vh = video.videoHeight;
614
+ if (!vw || !vh) {
615
+ hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
616
+ URL.revokeObjectURL(blobUrl); return;
617
+ }
618
+
619
  let cw, ch, sx, sy;
620
+ if (ratio === '1:1') {
621
+ const size = Math.min(vw, vh); cw = ch = size;
622
+ sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
623
+ } else if (ratio === '9:16') {
624
+ ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
625
+ sx = Math.floor((vw - cw) / 2); sy = 0;
626
+ } else { cw = vw; ch = vh; sx = sy = 0; }
627
+
628
  showProc(`🔄 Render ${cw}x${ch}...`);
629
+
630
+ const canvas = document.createElement('canvas');
631
+ canvas.width = cw; canvas.height = ch;
632
  const ctx = canvas.getContext('2d');
633
  const canvasStream = canvas.captureStream(30);
634
+
635
+ // Parallel video to capture audio from blob
636
  let recordStream = canvasStream;
637
  const audioVideo = document.createElement('video');
638
+ audioVideo.src = blobUrl;
639
+ audioVideo.muted = true;
640
+ audioVideo.volume = 0;
641
+ audioVideo.preload = 'auto';
642
+ audioVideo.playsInline = true;
643
+
644
  audioVideo.onloadedmetadata = function() {
645
+ try {
646
+ const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
647
+ if (aStr) {
648
+ const tracks = aStr.getAudioTracks();
649
+ if (tracks.length > 0) {
650
+ recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
651
+ console.log('[VTV Crop] Audio merged:', tracks.length, 'tracks');
652
+ }
653
+ }
654
+ } catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
655
  };
656
+
657
+ const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
658
+ ? 'video/webm;codecs=vp9,opus'
659
+ : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
660
+ ? 'video/webm;codecs=vp8,opus' : 'video/webm';
661
+
662
+ let rec;
663
+ try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
664
+ catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
665
+
666
  const chunks = [];
667
  rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
668
+ rec.onstop = () => {
669
+ hideProc();
670
+ URL.revokeObjectURL(blobUrl);
671
+ if (chunks.length === 0) {
672
+ recStatus('❌ Render thất bại (không có data). Thử lại hoặc chọn "Gốc".', 'err');
673
+ return;
674
+ }
675
+ const out = new Blob(chunks, {type: 'video/webm'});
676
+ if (action === 'download') {
677
+ downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
678
+ } else {
679
+ uploadToWall(out, title);
680
+ }
681
+ };
682
  rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
683
+
684
  rec.start(200);
685
+
686
  audioVideo.play().catch(() => {});
687
+
688
  video.play().then(() => {
689
  let finished = false;
690
+ function finish() {
691
+ if (finished) return;
692
+ finished = true;
693
+ console.log('[VTV Crop] finish() called');
694
+ try { rec.requestData(); } catch(e) {}
695
+ setTimeout(() => {
696
+ if (rec.state !== 'inactive') rec.stop();
697
+ try { audioVideo.pause(); } catch(e) {}
698
+ }, 300);
699
+ }
700
+ video.onended = finish;
701
+ audioVideo.onended = finish;
702
+
703
+ // TIMEOUT FALLBACK: if video.ended doesn't fire within expected duration + 3s
704
  const expectedDur = (video.duration || 0) * 1000;
705
+ if (expectedDur > 0 && isFinite(expectedDur)) {
706
+ setTimeout(() => {
707
+ if (!finished) {
708
+ console.warn('[VTV Crop] Timeout fallback after', expectedDur + 3000, 'ms');
709
+ finish();
710
+ }
711
+ }, expectedDur + 3000);
712
+ } else {
713
+ // If duration is unknown (e.g. blob), use a generous timeout
714
+ console.warn('[VTV Crop] Duration unknown, using 60s timeout');
715
+ setTimeout(() => {
716
+ if (!finished) {
717
+ console.warn('[VTV Crop] 60s timeout fallback');
718
+ finish();
719
+ }
720
+ }, 60000);
721
+ }
722
+
723
+ function draw() {
724
+ if (video.paused || video.ended) return;
725
+ ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
726
+ requestAnimationFrame(draw);
727
+ }
728
  draw();
729
+ }).catch(e => {
730
+ hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
731
+ URL.revokeObjectURL(blobUrl);
732
+ });
733
  }
734
+
735
+ video.onerror = function() {
736
+ hideProc(); recStatus('❌ Không đọc được video.', 'err');
737
+ URL.revokeObjectURL(blobUrl);
738
+ };
739
+
740
  video.addEventListener('loadedmetadata', onReady);
741
  video.addEventListener('canplay', onReady);
742
  }
 
749
  panel.classList.toggle('show', _rec.active);
750
  const btn = document.getElementById('vtv-rec-btn');
751
  if (btn) {
752
+ if (_rec.active) {
753
+ btn.style.background = '#3a1a1a';
754
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
755
+ } else {
756
+ btn.style.background = '';
757
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
758
+ document.getElementById('vtv-rec-panel').classList.remove('show');
759
+ }
760
  }
761
  }
762
 
 
809
  mini.classList.add('show'); mini.classList.remove('hidden');
810
  }
811
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812
  // ===== MAIN PLAYER =====
813
  async function loadAllStreams(){
814
  if(_loading) return; _loading=true;
815
  const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
816
  try{const r=await fetch('/api/vtv/streams'); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; const rawUrls=(i&&i.stream_urls)||(i&&i.stream_url?[i.stream_url]:[]); if(rawUrls.length){const urls=rawUrls.map(u=>NEEDS_PROXY.test(u)?'/api/proxy/m3u8/vtv?url='+encodeURIComponent(u):u); STREAMS[ch.id]=urls;} else STREAMS[ch.id]=[];});}}catch(e){}
817
  CHANNELS.forEach(ch=>{const t=document.getElementById('vtvt-'+ch.id); if(t){if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off'); t.textContent=ch.name;}else{t.style.opacity='0.35'; t.textContent=ch.name+' ✕';}}});
818
+ _loading=false; _streamsLoaded=true;
 
 
 
 
819
  }
820
 
821
+ function buildBlock(){
822
+ const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
823
+ let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
824
+ w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
825
+ '<div class="vtv-tabs">'+tabs+'</div>'+
826
+ '<div class="vtv-frame"><div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div><video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video><div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div></div>'+
827
+ '<div class="vtv-controls"><button class="vtv-pip-btn" id="vtv-pip-btn" onclick="window._vtvTogglePiP()"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="12" y="10" width="8" height="6" rx="1" fill="currentColor"/></svg>PiP</button><span style="flex:1"></span><span style="font-size:9px;color:#666" id="vtv-status"></span></div>'+
828
+ '<div class="vtv-epg" id="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" id="vtv-epg-toggle" onclick="window._vtvToggleEpg()">Ẩn</button></div><div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div></div>';
829
+ return w;
830
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
 
832
+ async function loadEpg(chId){
833
+ const el=document.getElementById('vtv-epg-list'); if(!el) return;
834
+ el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
835
+ try{const r=await fetch('/api/vtv/epg/'+chId); if(!r.ok) throw new Error(); const d=await r.json(); const p=d.programs||[]; if(!p.length){el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch</div>';return;} let h=''; p.forEach(x=>{const n=x.now?' now':''; h+='<div class="vtv-epg-item'+n+'"><span class="t">'+(x.time||'')+(x.end_time?'-'+x.end_time:'')+'</span> <span class="n">'+(x.title||'')+'</span></div>';}); el.innerHTML=h; const ni=el.querySelector('.vtv-epg-item.now'); if(ni) ni.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});}catch(e){el.innerHTML='<div class="vtv-epg-empty">Không tải được lịch</div>';}
 
 
 
 
 
 
 
 
836
  }
837
 
838
+ window._vtvToggleEpg=function(){const l=document.getElementById('vtv-epg-list'),b=document.getElementById('vtv-epg-toggle'); if(!l||!b)return; if(l.style.display==='none'){l.style.display='flex';b.textContent='Ẩn';}else{l.style.display='none';b.textContent='Hiện';}};
839
+ window._vtvTogglePiP=async function(){const v=document.getElementById('vtv-player'); const b=document.getElementById('vtv-pip-btn'); if(!v)return; try{if(document.pictureInPictureElement===v){await document.exitPictureInPicture();_pipActive=false;if(b)b.classList.remove('on');}else{await v.requestPictureInPicture();_pipActive=true;if(b)b.classList.add('on');}}catch(e){}};
840
+
841
+ function pinBlock(){
842
+ const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
843
+ h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
844
+ setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
845
+ if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
 
 
 
 
 
 
 
 
 
 
846
  }
847
 
848
+ window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
849
+
850
+ window._vtvPlay=function(chId){
851
+ const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
852
+ document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
853
+ const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
854
+ const video=document.getElementById('vtv-player'); const errEl=document.getElementById('vtv-err'); const loadEl=document.getElementById('vtv-load'); const errMsg=document.getElementById('vtv-err-msg');
855
+ video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
856
+ loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
857
+ if(_hls){_hls.destroy();_hls=null;}
858
+ // Force video element reset to prevent cached content from showing
859
+ video.removeAttribute('src'); video.pause(); video.load();
860
+ const urls=STREAMS[chId]||[];
861
+ if(!urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';return;}
862
+ // Small delay to ensure video element is fully reset
863
+ setTimeout(()=>{_tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);},50);
864
+ loadEpg(chId);
865
+ if(_miniActive){document.getElementById('vtv-mini-ch').textContent=ch.name;fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
866
+ };
867
 
868
+ function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
869
+ if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
870
+ const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
871
+ if(typeof Hls!=='undefined'&&Hls.isSupported()){
872
+ const hls=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:6,maxMaxBufferLength:10,liveSyncDurationCount:2,liveMaxLatencyDurationCount:3,fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
873
+ hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
874
+ let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{if(data.fatal){if(data.type===Hls.ErrorTypes.NETWORK_ERROR){rec++;if(rec<=3)setTimeout(()=>hls.startLoad(),2000);else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}});
875
+ }else if(video.canPlayType('application/vnd.apple.mpegurl')){
876
+ video.src=src;
877
+ video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
878
+ video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
879
+ }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
880
  }
881
 
882
+ // ===== NAV HOOKS =====
883
+ const _origShowView=window.showView;
884
+ window.showView=function(id){if(id==='view-home'&&_miniActive)closeMiniPlayer();else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origShowView.apply(this,arguments);};
885
+ const _origReadArticle=window.readArticle; if(_origReadArticle){window.readArticle=function(url){if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origReadArticle.apply(this,arguments);};}
886
+ const _origSwitchCat=window.switchCat; if(_origSwitchCat){window.switchCat=function(id){if(id==='home'){if(_miniActive)closeMiniPlayer();}else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origSwitchCat.apply(this,arguments);};}
887
 
888
+ const _origLoadHome=window.loadHome;
889
+ if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
890
+ window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
891
+ window.loadHome.__vtvWrapped=true;
892
+ }
893
+ })();