bep40 commited on
Commit
f9e1c03
·
verified ·
1 Parent(s): 98c133a

v8: DVR 5-min rewind buffer + default channel VTV6

Browse files

- Đổi DEFAULT_CHANNEL từ vtv3 → vtv6
- Thêm liveBackBufferLength=300s (5 phút DVR)
- Thêm thanh seek DVR để tua lại trước đó tối đa 5 phút
- Nút LIVE để quay về realtime
- Cập nhật hint recorder: tua DVR về trước 5′ r�i record

Files changed (1) hide show
  1. static/yt_live.js +133 -138
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;
@@ -14,11 +14,13 @@
14
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
15
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
16
  ];
17
- const DEFAULT_CHANNEL = 'vtv3';
 
18
  const NEEDS_PROXY = /fptplay\.net/;
19
  const STREAMS = {};
20
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
21
  let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
 
22
 
23
  // ===== RECORDER STATE =====
24
  const _rec = {
@@ -48,6 +50,18 @@
48
  .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
49
  .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
50
  @keyframes vtvspin{to{transform:rotate(360deg)}}
 
 
 
 
 
 
 
 
 
 
 
 
51
  .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
52
  .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
53
  .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
@@ -75,6 +89,7 @@
75
  .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
76
  .vtv-wrap.vtv-sticky .vtv-epg{display:none}
77
  .vtv-wrap.vtv-sticky .vtv-controls{display:none}
 
78
  .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
79
  .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
80
  .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
@@ -171,7 +186,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`);
@@ -248,7 +263,7 @@
248
  recBtn.id = 'vtv-rec-btn';
249
  recBtn.className = 'vtv-rec-btn';
250
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
251
- recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
252
  recBtn.onclick = toggleRecPanel;
253
  controls.appendChild(recBtn);
254
 
@@ -272,7 +287,7 @@
272
  <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
273
  <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
274
  </div>
275
- <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
276
  <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
277
  `;
278
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
@@ -435,7 +450,6 @@
435
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
436
  };
437
 
438
- // ===== RECORD — captureStream with audio (unmute during record) =====
439
  window._vtvRecGo = function() {
440
  if (_rec.startTime === null || _rec.endTime === null) return;
441
  if (_rec.isRecording) {
@@ -518,16 +532,13 @@
518
  }, dur + 500);
519
  };
520
 
521
- // ===== AI TITLE =====
522
  window._vtvRecGenAITitle = async function() {
523
  if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
524
  const btn = document.getElementById('vtv-rec-ai-btn');
525
  const input = document.getElementById('vtv-rec-title-input');
526
  if (!btn || !input) return;
527
-
528
  btn.disabled = true;
529
  btn.textContent = '⏳ Đang tạo...';
530
-
531
  try {
532
  const ch = CHANNELS.find(c => c.id === _currentCh);
533
  const channelName = ch ? ch.name : (_currentCh || 'VTV');
@@ -542,7 +553,6 @@
542
  }
543
  };
544
 
545
- // ===== DOWNLOAD =====
546
  window._vtvRecDownload = function() {
547
  if (!_rec.blob) return;
548
  const title = document.getElementById('vtv-rec-title-input')?.value || '';
@@ -553,7 +563,6 @@
553
  }
554
  };
555
 
556
- // ===== SHARE TO SHORT AI =====
557
  window._vtvRecShare = function() {
558
  if (!_rec.blob) return;
559
  const title = document.getElementById('vtv-rec-title-input')?.value || '';
@@ -564,10 +573,8 @@
564
  }
565
  };
566
 
567
- // ===== RATIO CROP — v7: reliable render with timeout fallback =====
568
  function processRatio(blob, ratio, action, title) {
569
  showProc(`🔄 Đang xử lý ${ratio}...`);
570
-
571
  const blobUrl = URL.createObjectURL(blob);
572
  const video = document.createElement('video');
573
  video.src = blobUrl;
@@ -578,136 +585,35 @@
578
  function onReady() {
579
  video.removeEventListener('loadedmetadata', onReady);
580
  video.removeEventListener('canplay', onReady);
581
-
582
  const vw = video.videoWidth, vh = video.videoHeight;
583
- if (!vw || !vh) {
584
- hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
585
- URL.revokeObjectURL(blobUrl); return;
586
- }
587
-
588
  let cw, ch, sx, sy;
589
- if (ratio === '1:1') {
590
- const size = Math.min(vw, vh); cw = ch = size;
591
- sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
592
- } else if (ratio === '9:16') {
593
- ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
594
- sx = Math.floor((vw - cw) / 2); sy = 0;
595
- } else { cw = vw; ch = vh; sx = sy = 0; }
596
-
597
  showProc(`🔄 Render ${cw}x${ch}...`);
598
-
599
- const canvas = document.createElement('canvas');
600
- canvas.width = cw; canvas.height = ch;
601
  const ctx = canvas.getContext('2d');
602
  const canvasStream = canvas.captureStream(30);
603
-
604
  let recordStream = canvasStream;
605
  const audioVideo = document.createElement('video');
606
- audioVideo.src = blobUrl;
607
- audioVideo.muted = true;
608
- audioVideo.volume = 0;
609
- audioVideo.preload = 'auto';
610
- audioVideo.playsInline = true;
611
-
612
- audioVideo.onloadedmetadata = function() {
613
- try {
614
- const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
615
- if (aStr) {
616
- const tracks = aStr.getAudioTracks();
617
- if (tracks.length > 0) {
618
- recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
619
- console.log('[VTV Crop] Audio merged:', tracks.length, 'tracks');
620
- }
621
- }
622
- } catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
623
- };
624
-
625
- const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
626
- ? 'video/webm;codecs=vp9,opus'
627
- : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
628
- ? 'video/webm;codecs=vp8,opus' : 'video/webm';
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(blobUrl); 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();
638
- URL.revokeObjectURL(blobUrl);
639
- if (chunks.length === 0) {
640
- recStatus('❌ Render thất bại (không có data). Thử lại hoặc chọn "Gốc".', 'err');
641
- return;
642
- }
643
- const out = new Blob(chunks, {type: 'video/webm'});
644
- if (action === 'download') {
645
- downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
646
- } else {
647
- uploadToWall(out, title);
648
- }
649
- };
650
  rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
651
-
652
  rec.start(200);
653
-
654
  audioVideo.play().catch(() => {});
655
-
656
- video.play().then(() => {
657
- let finished = false;
658
- function finish() {
659
- if (finished) return;
660
- finished = true;
661
- console.log('[VTV Crop] finish() called');
662
- try { rec.requestData(); } catch(e) {}
663
- setTimeout(() => {
664
- if (rec.state !== 'inactive') rec.stop();
665
- try { audioVideo.pause(); } catch(e) {}
666
- }, 300);
667
- }
668
- video.onended = finish;
669
- audioVideo.onended = finish;
670
-
671
- const expectedDur = (video.duration || 0) * 1000;
672
- if (expectedDur > 0 && isFinite(expectedDur)) {
673
- setTimeout(() => {
674
- if (!finished) {
675
- console.warn('[VTV Crop] Timeout fallback after', expectedDur + 3000, 'ms');
676
- finish();
677
- }
678
- }, expectedDur + 3000);
679
- } else {
680
- console.warn('[VTV Crop] Duration unknown, using 60s timeout');
681
- setTimeout(() => {
682
- if (!finished) {
683
- console.warn('[VTV Crop] 60s timeout fallback');
684
- finish();
685
- }
686
- }, 60000);
687
- }
688
-
689
- function draw() {
690
- if (video.paused || video.ended) return;
691
- ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
692
- requestAnimationFrame(draw);
693
- }
694
- draw();
695
- }).catch(e => {
696
- hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
697
- URL.revokeObjectURL(blobUrl);
698
- });
699
  }
700
-
701
- video.onerror = function() {
702
- hideProc(); recStatus('❌ Không đọc được video.', 'err');
703
- URL.revokeObjectURL(blobUrl);
704
- };
705
-
706
  video.addEventListener('loadedmetadata', onReady);
707
  video.addEventListener('canplay', onReady);
708
  }
709
 
710
- // ===== TOGGLE REC PANEL =====
711
  function toggleRecPanel() {
712
  const panel = document.getElementById('vtv-inline-rec');
713
  if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
@@ -715,17 +621,96 @@
715
  panel.classList.toggle('show', _rec.active);
716
  const btn = document.getElementById('vtv-rec-btn');
717
  if (btn) {
718
- if (_rec.active) {
719
- btn.style.background = '#3a1a1a';
720
- btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
721
- } else {
722
- btn.style.background = '';
723
- btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
724
- document.getElementById('vtv-rec-panel').classList.remove('show');
725
- }
726
  }
727
  }
728
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729
  // ===== PIN BUTTON =====
730
  function setupPinButton(){
731
  const block = document.getElementById('vtv-block');
@@ -807,7 +792,7 @@
807
  function pinBlock(){
808
  const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
809
  h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
810
- setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
811
  if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
812
  }
813
 
@@ -826,13 +811,23 @@
826
  _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
827
  loadEpg(chId);
828
  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(()=>{});}
 
 
829
  };
830
 
831
  function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
832
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
833
  const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
834
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
835
- 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);
 
 
 
 
 
 
 
 
836
  hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
837
  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);}}});
838
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v8 ===
2
+ // Features: DVR 5-min rewind buffer, PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
+ // v8: Add liveBackBufferLength=300s (5min DVR), seek bar for rewind, DEFAULT_CHANNEL=vtv6
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
14
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
15
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
16
  ];
17
+ const DEFAULT_CHANNEL = 'vtv6';
18
+ const DVR_BUFFER_SECONDS = 300; // 5 minutes DVR buffer
19
  const NEEDS_PROXY = /fptplay\.net/;
20
  const STREAMS = {};
21
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
22
  let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
23
+ let _dvrSeekBarTimer = null;
24
 
25
  // ===== RECORDER STATE =====
26
  const _rec = {
 
50
  .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
51
  .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
52
  @keyframes vtvspin{to{transform:rotate(360deg)}}
53
+ /* DVR Seek Bar */
54
+ .vtv-dvr-bar{display:none;align-items:center;gap:6px;padding:4px 8px 6px;background:#0a0f1a;border-top:1px solid #1a2a3a}
55
+ .vtv-dvr-bar.visible{display:flex}
56
+ .vtv-dvr-label{font-size:9px;color:#00ccff;font-weight:700;white-space:nowrap}
57
+ .vtv-dvr-track{flex:1;position:relative;height:20px;background:#0d1a2a;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #1a3a5a}
58
+ .vtv-dvr-buffer{position:absolute;top:0;bottom:0;left:0;background:rgba(0,102,204,0.15);border-right:2px solid #0066cc}
59
+ .vtv-dvr-live-dot{position:absolute;top:50%;transform:translate(-50%,-50%);width:8px;height:8px;border-radius:50%;background:#e74c3c;box-shadow:0 0 6px #e74c3c;z-index:3}
60
+ .vtv-dvr-handle{position:absolute;top:50%;transform:translate(-50%,-50%);width:12px;height:12px;border-radius:50%;background:#00ccff;border:2px solid #fff;box-shadow:0 0 4px rgba(0,204,255,0.5);z-index;cursor:grab}
61
+ .vtv-dvr-handle:active{cursor:grabbing}
62
+ .vtv-dvr-time{font-size:9px;color:#8ab4d8;white-space:nowrap;min-width:38px;text-align:right}
63
+ .vtv-dvr-live-btn{background:#1a0808;border:1px solid #660000;color:#ff6666;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;white-space:nowrap}
64
+ .vtv-dvr-live-btn:hover{background:#330000;color:#ff4444}
65
  .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
66
  .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
67
  .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
 
89
  .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
90
  .vtv-wrap.vtv-sticky .vtv-epg{display:none}
91
  .vtv-wrap.vtv-sticky .vtv-controls{display:none}
92
+ .vtv-wrap.vtv-sticky .vtv-dvr-bar{display:none!important}
93
  .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
94
  .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
95
  .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
 
186
  if (el) el.style.display = 'none';
187
  }
188
 
189
+ // ===== UPLOAD TO WALL (Short AI) =====
190
  function uploadToWall(blob, title, source) {
191
  const formData = new FormData();
192
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
 
263
  recBtn.id = 'vtv-rec-btn';
264
  recBtn.className = 'vtv-rec-btn';
265
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
266
+ recBtn.title = 'Record video ngắn từ LIVE (max 60s). Dùng DVR để tua lại trước đó tối đa 5 phút.';
267
  recBtn.onclick = toggleRecPanel;
268
  controls.appendChild(recBtn);
269
 
 
287
  <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
288
  <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
289
  </div>
290
+ <div class="rec-hint" id="vtv-rec-hint">💡 Tua DVR về trước tối đa 5′ rồi chọn điểm bắt đầu → kết thúc (max 60s)</div>
291
  <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
292
  `;
293
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
 
450
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
451
  };
452
 
 
453
  window._vtvRecGo = function() {
454
  if (_rec.startTime === null || _rec.endTime === null) return;
455
  if (_rec.isRecording) {
 
532
  }, dur + 500);
533
  };
534
 
 
535
  window._vtvRecGenAITitle = async function() {
536
  if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
537
  const btn = document.getElementById('vtv-rec-ai-btn');
538
  const input = document.getElementById('vtv-rec-title-input');
539
  if (!btn || !input) return;
 
540
  btn.disabled = true;
541
  btn.textContent = '⏳ Đang tạo...';
 
542
  try {
543
  const ch = CHANNELS.find(c => c.id === _currentCh);
544
  const channelName = ch ? ch.name : (_currentCh || 'VTV');
 
553
  }
554
  };
555
 
 
556
  window._vtvRecDownload = function() {
557
  if (!_rec.blob) return;
558
  const title = document.getElementById('vtv-rec-title-input')?.value || '';
 
563
  }
564
  };
565
 
 
566
  window._vtvRecShare = function() {
567
  if (!_rec.blob) return;
568
  const title = document.getElementById('vtv-rec-title-input')?.value || '';
 
573
  }
574
  };
575
 
 
576
  function processRatio(blob, ratio, action, title) {
577
  showProc(`🔄 Đang xử lý ${ratio}...`);
 
578
  const blobUrl = URL.createObjectURL(blob);
579
  const video = document.createElement('video');
580
  video.src = blobUrl;
 
585
  function onReady() {
586
  video.removeEventListener('loadedmetadata', onReady);
587
  video.removeEventListener('canplay', onReady);
 
588
  const vw = video.videoWidth, vh = video.videoHeight;
589
+ if (!vw || !vh) { hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err'); URL.revokeObjectURL(blobUrl); return; }
 
 
 
 
590
  let cw, ch, sx, sy;
591
+ 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); }
592
+ else if (ratio === '9:16') { ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16)); sx = Math.floor((vw - cw) / 2); sy = 0; }
593
+ else { cw = vw; ch = vh; sx = sy = 0; }
 
 
 
 
 
594
  showProc(`🔄 Render ${cw}x${ch}...`);
595
+ const canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch;
 
 
596
  const ctx = canvas.getContext('2d');
597
  const canvasStream = canvas.captureStream(30);
 
598
  let recordStream = canvasStream;
599
  const audioVideo = document.createElement('video');
600
+ audioVideo.src = blobUrl; audioVideo.muted = true; audioVideo.volume = 0; audioVideo.preload = 'auto'; audioVideo.playsInline = true;
601
+ audioVideo.onloadedmetadata = function() { 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) {} };
602
+ 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';
603
+ let rec; try { rec = new MediaRecorder(recordStream, {mimeType: mime}); } catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  const chunks = [];
605
  rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
606
+ 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); };
 
 
 
 
 
 
 
 
 
 
 
 
 
607
  rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
 
608
  rec.start(200);
 
609
  audioVideo.play().catch(() => {});
610
+ video.play().then(() => { let finished = false; 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); } video.onended = finish; audioVideo.onended = finish; const expectedDur = (video.duration || 0) * 1000; if (expectedDur > 0 && isFinite(expectedDur)) { setTimeout(() => { if (!finished) finish(); }, expectedDur + 3000); } else { setTimeout(() => { if (!finished) finish(); }, 60000); } function draw() { if (video.paused || video.ended) return; ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch); requestAnimationFrame(draw); } draw(); }).catch(e => { hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
611
  }
612
+ video.onerror = function() { hideProc(); recStatus('❌ Không đọc được video.', 'err'); URL.revokeObjectURL(blobUrl); };
 
 
 
 
 
613
  video.addEventListener('loadedmetadata', onReady);
614
  video.addEventListener('canplay', onReady);
615
  }
616
 
 
617
  function toggleRecPanel() {
618
  const panel = document.getElementById('vtv-inline-rec');
619
  if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
 
621
  panel.classList.toggle('show', _rec.active);
622
  const btn = document.getElementById('vtv-rec-btn');
623
  if (btn) {
624
+ 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'; }
625
+ 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'); }
 
 
 
 
 
 
626
  }
627
  }
628
 
629
+ // ===== DVR SEEK BAR =====
630
+ function buildDVRBar() {
631
+ const controls = document.querySelector('.vtv-controls');
632
+ if (!controls || document.getElementById('vtv-dvr-bar')) return;
633
+ const dvrBar = document.createElement('div');
634
+ dvrBar.id = 'vtv-dvr-bar';
635
+ dvrBar.className = 'vtv-dvr-bar';
636
+ dvrBar.innerHTML = `
637
+ <span class="vtv-dvr-label">⏪ DVR</span>
638
+ <div class="vtv-dvr-track" id="vtv-dvr-track">
639
+ <div class="vtv-dvr-buffer" id="vtv-dvr-buffer"></div>
640
+ <div class="vtv-dvr-live-dot" id="vtv-dvr-live-dot"></div>
641
+ <div class="vtv-dvr-handle" id="vtv-dvr-handle"></div>
642
+ </div>
643
+ <span class="vtv-dvr-time" id="vtv-dvr-time">LIVE</span>
644
+ <button class="vtv-dvr-live-btn" id="vtv-dvr-live-btn" title="Quay về LIVE">LIVE</button>
645
+ `;
646
+ controls.parentNode.insertBefore(dvrBar, controls);
647
+
648
+ // DVR seek interactions
649
+ const track = document.getElementById('vtv-dvr-track');
650
+ const handle = document.getElementById('vtv-dvr-handle');
651
+ const liveDot = document.getElementById('vtv-dvr-live-dot');
652
+ const buffer = document.getElementById('vtv-dvr-buffer');
653
+ const timeLabel = document.getElementById('vtv-dvr-time');
654
+ const liveBtn = document.getElementById('vtv-dvr-live-btn');
655
+ let isDragging = false;
656
+
657
+ function updateDVRUI() {
658
+ const video = document.getElementById('vtv-player');
659
+ if (!video || !_hls) return;
660
+ const curTime = video.currentTime;
661
+ const duration = video.duration;
662
+ if (!duration || !isFinite(duration)) return;
663
+ const bufferStart = Math.max(0, duration - DVR_BUFFER_SECONDS);
664
+ const bufferWidth = ((duration - bufferStart) / DVR_BUFFER_SECONDS) * 100;
665
+ const handlePos = ((curTime - bufferStart) / (duration - bufferStart)) * 100;
666
+ buffer.style.width = Math.min(100, bufferWidth) + '%';
667
+ buffer.style.left = (100 - Math.min(100, bufferWidth)) + '%';
668
+ liveDot.style.left = '100%';
669
+ handle.style.left = Math.max(0, Math.min(100, handlePos)) + '%';
670
+ const behind = duration - curTime;
671
+ if (behind < 3) { timeLabel.textContent = 'LIVE'; timeLabel.style.color = '#e74c3c'; }
672
+ else { timeLabel.textContent = '-' + fmtSec(behind); timeLabel.style.color = '#8ab4d8'; }
673
+ }
674
+
675
+ function seekFromTrack(clientX) {
676
+ const video = document.getElementById('vtv-player');
677
+ if (!video || !_hls) return;
678
+ const rect = track.getBoundingClientRect();
679
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
680
+ const duration = video.duration;
681
+ if (!duration || !isFinite(duration)) return;
682
+ const bufferStart = Math.max(0, duration - DVR_BUFFER_SECONDS);
683
+ const seekTime = bufferStart + pct * (duration - bufferStart);
684
+ video.currentTime = seekTime;
685
+ updateDVRUI();
686
+ }
687
+
688
+ track.addEventListener('click', function(e) { seekFromTrack(e.clientX); });
689
+ track.addEventListener('touchstart', function(e) { e.preventDefault(); seekFromTrack(e.touches[0].clientX); }, {passive: false});
690
+
691
+ handle.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); isDragging = true; });
692
+ handle.addEventListener('touchstart', function(e) { e.preventDefault(); e.stopPropagation(); isDragging = true; }, {passive: false});
693
+
694
+ document.addEventListener('mousemove', function(e) { if (isDragging) { e.preventDefault(); seekFromTrack(e.clientX); } });
695
+ document.addEventListener('touchmove', function(e) { if (isDragging) { e.preventDefault(); seekFromTrack(e.touches[0].clientX); } }, {passive: false});
696
+ document.addEventListener('mouseup', function() { isDragging = false; });
697
+ document.addEventListener('touchend', function() { isDragging = false; });
698
+
699
+ liveBtn.addEventListener('click', function() {
700
+ const video = document.getElementById('vtv-player');
701
+ if (video && _hls) { video.currentTime = video.duration; updateDVRUI(); }
702
+ });
703
+
704
+ // Update UI periodically
705
+ if (_dvrSeekBarTimer) clearInterval(_dvrSeekBarTimer);
706
+ _dvrSeekBarTimer = setInterval(updateDVRUI, 1000);
707
+ }
708
+
709
+ function showDVRBar() {
710
+ const bar = document.getElementById('vtv-dvr-bar');
711
+ if (bar) bar.classList.add('visible');
712
+ }
713
+
714
  // ===== PIN BUTTON =====
715
  function setupPinButton(){
716
  const block = document.getElementById('vtv-block');
 
792
  function pinBlock(){
793
  const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
794
  h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
795
+ setTimeout(()=>{setupPinButton();buildInlineRecorder();buildDVRBar();},500);
796
  if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
797
  }
798
 
 
811
  _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
812
  loadEpg(chId);
813
  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(()=>{});}
814
+ // Show DVR bar after stream loads
815
+ setTimeout(()=>{showDVRBar();},2000);
816
  };
817
 
818
  function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
819
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
820
  const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
821
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
822
+ // v8: Add liveBackBufferLength for 5-min DVR buffer
823
+ const hls=new Hls({
824
+ enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,
825
+ maxBufferLength:60,maxMaxBufferLength:120,
826
+ liveBackBufferLength:DVR_BUFFER_SECONDS,
827
+ liveSyncDurationCount:2,liveMaxLatencyDurationCount:5,
828
+ fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000
829
+ });
830
+ _hls=hls; hls.loadSource(src); hls.attachMedia(video);
831
  hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
832
  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);}}});
833
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){