bep40 commited on
Commit
f9fa322
·
verified ·
1 Parent(s): 5af1259

Restore static/yt_live.js to 0cc342c

Browse files
Files changed (1) hide show
  1. static/yt_live.js +35 -92
static/yt_live.js CHANGED
@@ -1,6 +1,6 @@
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;
@@ -171,7 +171,7 @@
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`);
@@ -179,56 +179,18 @@
179
  formData.append('source', source || 'vtv_inline_recorder');
180
  showProc('⏳ Đang upload lên Short AI...');
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;
188
- try { data = JSON.parse(text); } catch(e) {
189
- console.error('[VTV Upload] JSON parse failed:', e, 'Raw:', text.substring(0, 500));
190
- throw new Error('Server trả về lỗi (không phải JSON). Status: ' + r.status);
191
- }
192
- return data;
193
- });
194
- })
195
  .then(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 {
226
- throw new Error(data && data.error ? data.error : 'Upload thất bại (ok=false)');
227
  }
228
  })
229
  .catch(e => {
230
  hideProc();
231
- console.error('[VTV Upload] Error:', e);
232
  recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
233
  setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
234
  throw e;
@@ -237,6 +199,7 @@
237
 
238
  // ===== AI TITLE GENERATION =====
239
  async function generateAITitle(blob, channelName) {
 
240
  return new Promise((resolve) => {
241
  const video = document.createElement('video');
242
  video.src = URL.createObjectURL(blob);
@@ -244,6 +207,8 @@
244
  video.onloadedmetadata = function() {
245
  URL.revokeObjectURL(video.src);
246
  const dur = Math.round(video.duration || 0);
 
 
247
  const now = new Date();
248
  const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
249
  const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
@@ -254,6 +219,7 @@
254
  `⚡ ${channelName} Highlights - ${dateStr}`,
255
  `📱 ${channelName} Short - ${timeStr} ${dateStr}`,
256
  ];
 
257
  const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
258
  resolve(titles[idx % titles.length]);
259
  };
@@ -481,6 +447,7 @@
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
 
@@ -523,6 +490,7 @@
523
  pv.src = URL.createObjectURL(_rec.blob);
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 => {
@@ -531,7 +499,7 @@
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');
@@ -590,22 +558,22 @@
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);
@@ -613,7 +581,7 @@
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;
@@ -635,7 +603,7 @@
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';
@@ -661,15 +629,14 @@
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'});
@@ -679,7 +646,7 @@
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
 
@@ -690,7 +657,6 @@
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();
@@ -700,26 +666,6 @@
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);
@@ -728,13 +674,13 @@
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);
@@ -802,7 +748,7 @@
802
  function activateMiniPlayer(){
803
  if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
804
  const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
805
- if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:4,fragLoadingTimeOut:8000,manifestLoadingTimeOut:8000}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
806
  else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
807
  const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
808
  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(()=>{});
@@ -813,7 +759,7 @@
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
  }
@@ -832,7 +778,7 @@
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';}};
@@ -855,12 +801,9 @@
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
  };
@@ -869,7 +812,7 @@
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')){
@@ -890,4 +833,4 @@
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
- })();
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v6 ===
2
  // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
+ // FIX v6: audio from captureStream (unmute during record), video.ended for crop, AI title generation
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
171
  if (el) el.style.display = 'none';
172
  }
173
 
174
+ // ===== UPLOAD TO WALL (Short AI) =====
175
  function uploadToWall(blob, title, source) {
176
  const formData = new FormData();
177
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
 
179
  formData.append('source', source || 'vtv_inline_recorder');
180
  showProc('⏳ Đang upload lên Short AI...');
181
  return fetch('/api/wall', { method: 'POST', body: formData })
182
+ .then(r => r.json())
 
 
 
 
 
 
 
 
 
 
 
 
183
  .then(data => {
184
  hideProc();
185
+ if (data.ok) {
 
186
  recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  return data;
188
  } else {
189
+ throw new Error(data.error || 'Upload lỗi');
190
  }
191
  })
192
  .catch(e => {
193
  hideProc();
 
194
  recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
195
  setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
196
  throw e;
 
199
 
200
  // ===== AI TITLE GENERATION =====
201
  async function generateAITitle(blob, channelName) {
202
+ // Get video info for AI context
203
  return new Promise((resolve) => {
204
  const video = document.createElement('video');
205
  video.src = URL.createObjectURL(blob);
 
207
  video.onloadedmetadata = function() {
208
  URL.revokeObjectURL(video.src);
209
  const dur = Math.round(video.duration || 0);
210
+ const w = video.videoWidth, h = video.videoHeight;
211
+ // Generate title based on channel + duration + timestamp
212
  const now = new Date();
213
  const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
214
  const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
 
219
  `⚡ ${channelName} Highlights - ${dateStr}`,
220
  `📱 ${channelName} Short - ${timeStr} ${dateStr}`,
221
  ];
222
+ // Pick one based on duration
223
  const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
224
  resolve(titles[idx % titles.length]);
225
  };
 
447
  // CRITICAL: Unmute video so captureStream includes audio tracks
448
  const wasMuted = video.muted;
449
  video.muted = false;
450
+ // Also set volume low so user doesn't hear double audio
451
  const wasVolume = video.volume;
452
  video.volume = 0.3;
453
 
 
490
  pv.src = URL.createObjectURL(_rec.blob);
491
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
492
  const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
493
+ const hasAudio = _rec.blob.size > 50000; // rough heuristic
494
  recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
495
  };
496
  _rec.recorder.onerror = e => {
 
499
  recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
500
  };
501
 
502
+ _rec.recorder.start(500); // 500ms timeslice
503
  video.currentTime = _rec.startTime;
504
 
505
  const btn = document.getElementById('vtv-rec-btn');
 
558
  }
559
  };
560
 
561
+ // ===== RATIO CROP — v6: reliable render =====
562
+ // Key fixes:
563
+ // 1. video.volume=0 (not muted) to avoid autoplay block
564
+ // 2. video.ended event to stop (NOT setTimeout with duration)
565
+ // 3. 200ms timeslice for frequent data collection
566
+ // 4. rec.requestData() flush before stop
567
+ // 5. Parallel audioVideo element to capture audio from blob
568
  function processRatio(blob, ratio, action, title) {
569
  showProc(`🔄 Đang xử lý ${ratio}...`);
570
 
 
571
  const video = document.createElement('video');
572
+ video.src = URL.createObjectURL(blob);
573
  video.volume = 0;
574
  video.preload = 'auto';
575
  video.playsInline = true;
576
 
 
 
 
 
 
577
  function onReady() {
578
  video.removeEventListener('loadedmetadata', onReady);
579
  video.removeEventListener('canplay', onReady);
 
581
  const vw = video.videoWidth, vh = video.videoHeight;
582
  if (!vw || !vh) {
583
  hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
584
+ URL.revokeObjectURL(video.src); return;
585
  }
586
 
587
  let cw, ch, sx, sy;
 
603
  // Parallel video to capture audio from blob
604
  let recordStream = canvasStream;
605
  const audioVideo = document.createElement('video');
606
+ audioVideo.src = video.src;
607
  audioVideo.muted = true;
608
  audioVideo.volume = 0;
609
  audioVideo.preload = 'auto';
 
629
 
630
  let rec;
631
  try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
632
+ catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(video.src); return; }
633
 
634
  const chunks = [];
635
  rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
636
  rec.onstop = () => {
637
+ hideProc(); URL.revokeObjectURL(video.src);
 
638
  if (chunks.length === 0) {
639
+ recStatus('❌ Render thất bại. Thử lại hoặc chọn "Gốc".', 'err');
640
  return;
641
  }
642
  const out = new Blob(chunks, {type: 'video/webm'});
 
646
  uploadToWall(out, title);
647
  }
648
  };
649
+ rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(video.src); };
650
 
651
  rec.start(200);
652
 
 
657
  function finish() {
658
  if (finished) return;
659
  finished = true;
 
660
  try { rec.requestData(); } catch(e) {}
661
  setTimeout(() => {
662
  if (rec.state !== 'inactive') rec.stop();
 
666
  video.onended = finish;
667
  audioVideo.onended = finish;
668
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  function draw() {
670
  if (video.paused || video.ended) return;
671
  ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
 
674
  draw();
675
  }).catch(e => {
676
  hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
677
+ URL.revokeObjectURL(video.src);
678
  });
679
  }
680
 
681
  video.onerror = function() {
682
  hideProc(); recStatus('❌ Không đọc được video.', 'err');
683
+ URL.revokeObjectURL(video.src);
684
  };
685
 
686
  video.addEventListener('loadedmetadata', onReady);
 
748
  function activateMiniPlayer(){
749
  if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
750
  const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
751
+ if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:10}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
752
  else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
753
  const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
754
  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(()=>{});
 
759
  async function loadAllStreams(){
760
  if(_loading) return; _loading=true;
761
  const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
762
+ try{const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)}); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u=i.stream_url; if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u); STREAMS[ch.id]=[u];} else STREAMS[ch.id]=[];});}}catch(e){}
763
  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+' ✕';}}});
764
  _loading=false; _streamsLoaded=true;
765
  }
 
778
  async function loadEpg(chId){
779
  const el=document.getElementById('vtv-epg-list'); if(!el) return;
780
  el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
781
+ try{const r=await fetch('/api/vtv/epg/'+chId,{signal:AbortSignal.timeout(10000)}); 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>';}
782
  }
783
 
784
  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';}};
 
801
  video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
802
  loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
803
  if(_hls){_hls.destroy();_hls=null;}
 
 
804
  const urls=STREAMS[chId]||[];
805
  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;}
806
+ _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
 
807
  loadEpg(chId);
808
  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(()=>{});}
809
  };
 
812
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
813
  const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
814
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
815
+ const hls=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:20}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
816
  hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
817
  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);}}});
818
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
 
833
  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;};
834
  window.loadHome.__vtvWrapped=true;
835
  }
836
+ })();