bep40 commited on
Commit
8299bb2
·
verified ·
1 Parent(s): e072bab

Upload static/yt_live.js

Browse files
Files changed (1) hide show
  1. static/yt_live.js +253 -318
static/yt_live.js CHANGED
@@ -1,6 +1,7 @@
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;
@@ -19,12 +20,14 @@
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 = {
25
  active: false, startTime: null, endTime: null,
26
  isRecording: false, recorder: null, chunks: [], blob: null,
27
  ratio: 'original', _dragMarker: null, _recTimer: null,
 
28
  };
29
 
30
  // ===== STYLES =====
@@ -48,11 +51,15 @@
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}
54
  .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
55
  .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
 
 
 
 
56
  .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
57
  .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
58
  .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
@@ -128,6 +135,20 @@
128
  .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
129
  .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
130
  .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
132
  .vtv-mini.show{display:block}
133
  .vtv-mini.hidden{transform:translateY(-88%)}
@@ -171,7 +192,6 @@
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`);
@@ -210,7 +230,6 @@
210
  });
211
  }
212
 
213
- // ===== AI TITLE GENERATION =====
214
  async function generateAITitle(blob, channelName) {
215
  return new Promise((resolve) => {
216
  const video = document.createElement('video');
@@ -239,11 +258,63 @@
239
  });
240
  }
241
 
242
- // ===== BUILD INLINE RECORDER =====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  function buildInlineRecorder() {
244
  const controls = document.querySelector('.vtv-controls');
245
  if (!controls || document.getElementById('vtv-rec-btn')) return;
246
 
 
 
 
 
 
 
 
 
247
  const recBtn = document.createElement('button');
248
  recBtn.id = 'vtv-rec-btn';
249
  recBtn.className = 'vtv-rec-btn';
@@ -272,11 +343,36 @@
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);
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  const recPanel = document.createElement('div');
281
  recPanel.id = 'vtv-rec-panel';
282
  recPanel.className = 'vtv-rec-panel';
@@ -303,7 +399,7 @@
303
  <span id="vtv-rec-proc-text">Đang xử lý...</span>
304
  </div>
305
  `;
306
- inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
307
 
308
  document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
309
  if (!_rec.isRecording) handleRecBarClick(e);
@@ -315,7 +411,6 @@
315
  document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
316
  }
317
 
318
- // Global drag
319
  document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
320
  document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
321
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
@@ -379,7 +474,6 @@
379
  document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
380
  }
381
 
382
- // ===== RECORDER CONTROLS =====
383
  window._vtvRecSetStart = function() {
384
  const video = document.getElementById('vtv-player');
385
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
@@ -414,6 +508,12 @@
414
  _rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
415
  if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
416
  clearTimeout(_rec._recTimer);
 
 
 
 
 
 
417
  document.getElementById('vtv-rec-mk-start').style.display = 'none';
418
  document.getElementById('vtv-rec-mk-end').style.display = 'none';
419
  document.getElementById('vtv-rec-progress').style.width = '0%';
@@ -435,7 +535,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,339 +617,175 @@
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');
534
- const title = await generateAITitle(_rec.blob, channelName);
535
- input.value = title;
536
- recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
537
- } catch(e) {
538
- recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
539
- } finally {
540
- btn.disabled = false;
541
- btn.textContent = '🤖 AI tạo tiêu đề';
542
  }
543
- };
544
 
545
- // ===== DOWNLOAD =====
546
- window._vtvRecDownload = function() {
547
- if (!_rec.blob) return;
548
- const title = document.getElementById('vtv-rec-title-input')?.value || '';
549
- if (_rec.ratio === 'original') {
550
- downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
551
- } else {
552
- processRatio(_rec.blob, _rec.ratio, 'download', title);
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 || '';
560
- if (_rec.ratio !== 'original') {
561
- processRatio(_rec.blob, _rec.ratio, 'upload', title);
562
- } else {
563
- uploadToWall(_rec.blob, title);
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;
574
- video.volume = 0;
575
- video.preload = 'auto';
576
- video.playsInline = true;
577
-
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; }
714
- _rec.active = !_rec.active;
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');
732
- if(!block) return;
733
- const header = block.querySelector('.vtv-head');
734
- if(!header || document.getElementById('vtv-pin-btn')) return;
735
- const btn = document.createElement('button');
736
- btn.id = 'vtv-pin-btn'; btn.className = 'vtv-pin-btn';
737
- btn.innerHTML = '📌 Ghim';
738
- btn.title = 'Ghim VTV cố định trên đầu trang';
739
- btn.onclick = function(e){
740
- e.stopPropagation(); _vtvPinned = !_vtvPinned;
741
- if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
742
- else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
 
 
 
 
 
743
  };
744
- header.style.position = 'relative';
745
- header.appendChild(btn);
746
- }
747
-
748
- // ===== MINI PLAYER =====
749
- function createMiniPlayer(){
750
- if(document.getElementById('vtv-mini')) return;
751
- const mini = document.createElement('div');
752
- mini.className = 'vtv-mini'; mini.id = 'vtv-mini';
753
- mini.innerHTML = `<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
754
- <div class="vtv-mini-bar"><div style="display:flex;align-items:center;flex:1;min-width:0"><span class="vtv-mini-ch" id="vtv-mini-ch">VTV</span><span class="vtv-mini-epg" id="vtv-mini-epg"></span></div>
755
- <div class="vtv-mini-btns"><button class="vtv-mini-btn" id="vtv-mini-pip">📺</button><button class="vtv-mini-btn" id="vtv-mini-expand">⬆️</button><button class="vtv-mini-btn" id="vtv-mini-hide">⬇️</button><button class="vtv-mini-btn x" id="vtv-mini-close">✕</button></div></div>
756
- <div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
757
- document.body.appendChild(mini);
758
- document.getElementById('vtv-mini-pip').onclick = async function(){
759
- const v = document.getElementById('vtv-mini-vid');
760
- try{ if(document.pictureInPictureElement === v) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); }catch(e){}
761
  };
762
- document.getElementById('vtv-mini-expand').onclick = function(){ closeMiniPlayer(); if(typeof switchCat==='function') switchCat('home'); };
763
- document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
764
- document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
765
- document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
766
- }
767
- function closeMiniPlayer(){ const m=document.getElementById('vtv-mini'); if(m){const v=document.getElementById('vtv-mini-vid'); if(v){v.pause();v.src='';} m.remove();} _miniActive=false; }
768
- function activateMiniPlayer(){
769
- if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
770
- const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
771
- 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(()=>{});}
772
- else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
773
- const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
774
- 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(()=>{});
775
- mini.classList.add('show'); mini.classList.remove('hidden');
776
- }
777
 
778
- // ===== MAIN PLAYER =====
779
- async function loadAllStreams(){
780
- if(_loading) return; _loading=true;
781
- const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
782
- try{const r=await fetch('/api/vtv/streams'); 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){}
783
- 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+' ✕';}}});
784
- _loading=false; _streamsLoaded=true;
785
- }
786
 
787
- function buildBlock(){
788
- const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
789
- let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
790
- w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
791
- '<div class="vtv-tabs">'+tabs+'</div>'+
792
- '<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>'+
793
- '<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>'+
794
- '<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>';
795
- return w;
796
- }
797
 
798
- async function loadEpg(chId){
799
- const el=document.getElementById('vtv-epg-list'); if(!el) return;
800
- el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
801
- 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>';}
802
- }
803
 
804
- 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';}};
805
- 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){}};
 
 
 
806
 
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
 
814
- window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
815
-
816
- window._vtvPlay=function(chId){
817
- const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
818
- document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
819
- const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
820
- 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');
821
- video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
822
- loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
823
- if(_hls){_hls.destroy();_hls=null;}
824
- const urls=STREAMS[chId]||[];
825
- 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;}
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')){
839
- video.src=src;
840
- video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
841
- video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
842
- }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
843
- }
844
 
845
- // ===== NAV HOOKS =====
846
- const _origShowView=window.showView;
847
- 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);};
848
- 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);};}
849
- 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);};}
850
 
851
- const _origLoadHome=window.loadHome;
852
- if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
853
- 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;};
854
- window.loadHome.__vtvWrapped=true;
855
- }
856
- })();
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v8 ===
2
  // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
+ // NEW v8: "Xem từ đầu" (Watch from start), "Record từ đầu đến giờ tùy chỉnh" (Record from start to custom end time)
4
+ // FIX v8: EPG date fix in backend, recorder supports scheduled recording from stream start
5
 
6
  (function(){
7
  if(window._ytLiveLoaded) return;
 
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 _watchFromStart = false;
24
 
25
  // ===== RECORDER STATE =====
26
  const _rec = {
27
  active: false, startTime: null, endTime: null,
28
  isRecording: false, recorder: null, chunks: [], blob: null,
29
  ratio: 'original', _dragMarker: null, _recTimer: null,
30
+ scheduleMode: false, scheduleEndTime: null, _scheduleTimer: null,
31
  };
32
 
33
  // ===== STYLES =====
 
51
  .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
52
  .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
53
  @keyframes vtvspin{to{transform:rotate(360deg)}}
54
+ .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a;flex-wrap:wrap}
55
  .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}
56
  .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
57
  .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
58
  .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
59
+ .vtv-from-start-btn{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
60
+ .vtv-from-start-btn:hover{background:#2d6a2d;color:#fff}
61
+ .vtv-from-start-btn.on{background:#2d8659;border-color:#5cb87a;color:#fff}
62
+ .vtv-from-start-btn svg{width:12px;height:12px;fill:currentColor}
63
  .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
64
  .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
65
  .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
 
135
  .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
136
  .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
137
  .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
138
+ .vtv-schedule-rec{display:none;padding:8px 10px;background:#0a1a0a;border-top:1px solid #1a3a1a}
139
+ .vtv-schedule-rec.show{display:block}
140
+ .vtv-schedule-rec .sch-title{font-size:10px;font-weight:700;color:#5cb87a;margin-bottom:6px}
141
+ .vtv-schedule-rec .sch-row{display:flex;gap:4px;align-items:center;margin-bottom:4px}
142
+ .vtv-schedule-rec .sch-row label{font-size:9px;color:#8ab4d8;white-space:nowrap}
143
+ .vtv-schedule-rec .sch-row input[type=time]{background:#1a2a3a;border:1px solid #2a3a4a;border-radius:4px;color:#ccc;font-size:10px;padding:3px 6px;flex:1}
144
+ .vtv-schedule-rec .sch-row input[type=date]{background:#1a2a3a;border:1px solid #2a3a4a;border-radius:4px;color:#ccc;font-size:10px;padding:3px 6px;flex:1}
145
+ .vtv-schedule-rec .sch-btn-row{display:flex;gap:4px;margin-top:6px}
146
+ .vtv-schedule-rec .sch-btn-row button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
147
+ .vtv-schedule-rec .sch-btn-row .sch-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
148
+ .vtv-schedule-rec .sch-btn-row .sch-cancel{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
149
+ .vtv-schedule-rec .sch-status{font-size:9px;color:#888;text-align:center;margin-top:4px}
150
+ .vtv-schedule-rec .sch-status.active{color:#5cb87a}
151
+ .vtv-schedule-rec .sch-status.error{color:#e74c3c}
152
  .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
153
  .vtv-mini.show{display:block}
154
  .vtv-mini.hidden{transform:translateY(-88%)}
 
192
  if (el) el.style.display = 'none';
193
  }
194
 
 
195
  function uploadToWall(blob, title, source) {
196
  const formData = new FormData();
197
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
 
230
  });
231
  }
232
 
 
233
  async function generateAITitle(blob, channelName) {
234
  return new Promise((resolve) => {
235
  const video = document.createElement('video');
 
258
  });
259
  }
260
 
261
+ // ===== WATCH FROM START (v8) =====
262
+ window._vtvToggleFromStart = function() {
263
+ _watchFromStart = !_watchFromStart;
264
+ const btn = document.getElementById('vtv-from-start-btn');
265
+ const video = document.getElementById('vtv-player');
266
+ if (!btn) return;
267
+
268
+ if (_watchFromStart) {
269
+ btn.classList.add('on');
270
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 5V1l-7 7 7 7V13c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg> Từ đầu';
271
+ if (video) {
272
+ setTimeout(() => {
273
+ if (video.seekable && video.seekable.length > 0) {
274
+ const start = video.seekable.start(0);
275
+ video.currentTime = start;
276
+ recStatus('⏪ Đang xem từ đầu stream (' + fmtSec(start) + ')', 'ok');
277
+ } else if (_hls) {
278
+ try {
279
+ const level = _hls.levels[_hls.currentLevel];
280
+ if (level && level.details && level.details.fragments && level.details.fragments.length > 0) {
281
+ const firstFrag = level.details.fragments[0];
282
+ if (firstFrag && firstFrag.start !== undefined) {
283
+ video.currentTime = firstFrag.start;
284
+ recStatus('⏪ Đang xem từ đầu stream', 'ok');
285
+ }
286
+ }
287
+ } catch(e) {
288
+ recStatus('⏪ Xem từ đầu: chờ buffer...', '');
289
+ }
290
+ }
291
+ }, 500);
292
+ }
293
+ } else {
294
+ btn.classList.remove('on');
295
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 5V1l-7 7 7 7V13c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg> Xem từ đầu';
296
+ if (video) {
297
+ if (video.seekable && video.seekable.length > 0) {
298
+ video.currentTime = video.seekable.end(video.seekable.length - 1);
299
+ }
300
+ recStatus('🔴 Đã chuyển về LIVE', '');
301
+ }
302
+ }
303
+ };
304
+
305
+ // ===== BUILD INLINE RECORDER (v8) =====
306
  function buildInlineRecorder() {
307
  const controls = document.querySelector('.vtv-controls');
308
  if (!controls || document.getElementById('vtv-rec-btn')) return;
309
 
310
+ const fromStartBtn = document.createElement('button');
311
+ fromStartBtn.id = 'vtv-from-start-btn';
312
+ fromStartBtn.className = 'vtv-from-start-btn';
313
+ fromStartBtn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M12 5V1l-7 7 7 7V13c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg> Xem từ đầu';
314
+ fromStartBtn.title = 'Xem lại từ thời điểm stream bắt đầu';
315
+ fromStartBtn.onclick = window._vtvToggleFromStart;
316
+ controls.appendChild(fromStartBtn);
317
+
318
  const recBtn = document.createElement('button');
319
  recBtn.id = 'vtv-rec-btn';
320
  recBtn.className = 'vtv-rec-btn';
 
343
  <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
344
  <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
345
  </div>
346
+ <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s) hoặc dùng "Lên lịch record"</div>
347
  <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
348
  `;
349
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
350
 
351
+ const scheduleRec = document.createElement('div');
352
+ scheduleRec.id = 'vtv-schedule-rec';
353
+ scheduleRec.className = 'vtv-schedule-rec';
354
+ const now = new Date();
355
+ const later = new Date(now.getTime() + 60 * 60 * 1000);
356
+ const dateStr = now.toISOString().split('T')[0];
357
+ const endTimeStr = later.toTimeString().substring(0, 5);
358
+ scheduleRec.innerHTML = `
359
+ <div class="sch-title">📅 Lên lịch record từ đầu đến giờ tùy chỉnh</div>
360
+ <div class="sch-row">
361
+ <label>Ngày:</label>
362
+ <input type="date" id="vtv-sch-date" value="${dateStr}">
363
+ </div>
364
+ <div class="sch-row">
365
+ <label>Record đến:</label>
366
+ <input type="time" id="vtv-sch-time" value="${endTimeStr}">
367
+ </div>
368
+ <div class="sch-btn-row">
369
+ <button class="sch-start" onclick="window._vtvSchStart()">⏺ Bắt đầu lên lịch</button>
370
+ <button class="sch-cancel" onclick="window._vtvSchCancel()" id="vtv-sch-cancel-btn" disabled>✕ Hủy</button>
371
+ </div>
372
+ <div class="sch-status" id="vtv-sch-status">Record từ thời điểm stream hoạt động đến giờ chỉ định</div>
373
+ `;
374
+ inlineRec.parentNode.insertBefore(scheduleRec, inlineRec.nextSibling);
375
+
376
  const recPanel = document.createElement('div');
377
  recPanel.id = 'vtv-rec-panel';
378
  recPanel.className = 'vtv-rec-panel';
 
399
  <span id="vtv-rec-proc-text">Đang xử lý...</span>
400
  </div>
401
  `;
402
+ scheduleRec.parentNode.insertBefore(recPanel, scheduleRec.nextSibling);
403
 
404
  document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
405
  if (!_rec.isRecording) handleRecBarClick(e);
 
411
  document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
412
  }
413
 
 
414
  document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
415
  document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
416
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
 
474
  document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
475
  }
476
 
 
477
  window._vtvRecSetStart = function() {
478
  const video = document.getElementById('vtv-player');
479
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
 
508
  _rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
509
  if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
510
  clearTimeout(_rec._recTimer);
511
+ if (_rec._scheduleTimer) { clearTimeout(_rec._scheduleTimer); _rec._scheduleTimer = null; }
512
+ _rec.scheduleMode = false;
513
+ const schCancelBtn = document.getElementById('vtv-sch-cancel-btn');
514
+ if (schCancelBtn) schCancelBtn.disabled = true;
515
+ const schStatus = document.getElementById('vtv-sch-status');
516
+ if (schStatus) { schStatus.textContent = 'Record từ thời điểm stream hoạt động đến giờ chỉ định'; schStatus.className = 'sch-status'; }
517
  document.getElementById('vtv-rec-mk-start').style.display = 'none';
518
  document.getElementById('vtv-rec-mk-end').style.display = 'none';
519
  document.getElementById('vtv-rec-progress').style.width = '0%';
 
535
  document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
536
  };
537
 
 
538
  window._vtvRecGo = function() {
539
  if (_rec.startTime === null || _rec.endTime === null) return;
540
  if (_rec.isRecording) {
 
617
  }, dur + 500);
618
  };
619
 
620
+ // ===== v8: SCHEDULED RECORDING FROM START =====
621
+ window._vtvSchStart = function() {
622
+ const video = document.getElementById('vtv-player');
623
+ if (!video || !video.src) {
624
+ const schStatus = document.getElementById('vtv-sch-status');
625
+ if (schStatus) { schStatus.textContent = '⚠️ Chưa có stream! Chọn kênh trước.'; schStatus.className = 'sch-status error'; }
626
+ return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  }
 
628
 
629
+ const dateInput = document.getElementById('vtv-sch-date');
630
+ const timeInput = document.getElementById('vtv-sch-time');
631
+ if (!dateInput || !timeInput || !dateInput.value || !timeInput.value) {
632
+ const schStatus = document.getElementById('vtv-sch-status');
633
+ if (schStatus) { schStatus.textContent = '⚠️ Chọn ngày và giờ kết thúc!'; schStatus.className = 'sch-status error'; }
634
+ return;
 
 
635
  }
 
636
 
637
+ const targetEnd = new Date(dateInput.value + 'T' + timeInput.value + ':00');
638
+ const now = new Date();
639
+
640
+ if (targetEnd <= now) {
641
+ const schStatus = document.getElementById('vtv-sch-status');
642
+ if (schStatus) { schStatus.textContent = '⚠️ Giờ kết thúc phải ở tương lai!'; schStatus.className = 'sch-status error'; }
643
+ return;
 
644
  }
 
645
 
646
+ const durationMs = targetEnd - now;
647
+ const durationMin = Math.round(durationMs / 60000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
 
649
+ _rec.scheduleMode = true;
650
+ _rec.scheduleEndTime = targetEnd;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
 
652
+ const schStatus = document.getElementById('vtv-sch-status');
653
+ const schCancelBtn = document.getElementById('vtv-sch-cancel-btn');
654
+ if (schStatus) { schStatus.textContent = `⏳ Đã lên lịch! Record từ BÂY GIỮ (${now.toLocaleTimeString('vi-VN', {hour:'2-digit',minute:'2-digit'})}) đến ${timeInput.value} (${durationMin} phút)`; schStatus.className = 'sch-status active'; }
655
+ if (schCancelBtn) schCancelBtn.disabled = false;
656
 
657
+ let startTime = 0;
658
+ if (video.seekable && video.seekable.length > 0) {
659
+ startTime = video.seekable.start(0);
660
+ }
661
 
662
+ _rec.chunks = [];
663
+ _rec.isRecording = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
 
665
+ const wasMuted = video.muted;
666
+ video.muted = false;
667
+ const wasVolume = video.volume;
668
+ video.volume = 0.5;
669
+
670
+ let stream;
671
+ try {
672
+ stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
673
+ if (!stream) throw new Error('captureStream not supported');
674
+ } catch(e) {
675
+ video.muted = wasMuted; video.volume = wasVolume;
676
+ _rec.isRecording = false;
677
+ _rec.scheduleMode = false;
678
+ if (schStatus) { schStatus.textContent = '❌ ' + e.message; schStatus.className = 'sch-status error'; }
679
+ return;
680
  }
681
 
682
+ const audioTracks = stream.getAudioTracks();
 
 
 
683
 
684
+ let mimeType = 'video/webm;codecs=vp9,opus';
685
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
686
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
687
 
688
+ try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
689
+ catch(e) {
690
+ video.muted = wasMuted; video.volume = wasVolume;
691
+ _rec.isRecording = false;
692
+ _rec.scheduleMode = false;
693
+ if (schStatus) { schStatus.textContent = '❌ Lỗi MediaRecorder: ' + e.message; schStatus.className = 'sch-status error'; }
694
+ return;
 
 
 
 
 
 
 
 
 
695
  }
 
696
 
697
+ _rec.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _rec.chunks.push(e.data); };
698
+ _rec.recorder.onstop = function() {
699
+ _rec.isRecording = false;
700
+ video.muted = wasMuted;
701
+ video.volume = wasVolume;
702
+ _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
703
+ _rec.scheduleMode = false;
704
+ const btn = document.getElementById('vtv-rec-btn');
705
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
706
+ document.getElementById('vtv-rec-panel').classList.add('show');
707
+ const pv = document.getElementById('vtv-rec-preview-vid');
708
+ pv.src = URL.createObjectURL(_rec.blob);
709
+ document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
710
+ const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
711
+ const schStatus2 = document.getElementById('vtv-sch-status');
712
+ if (schStatus2) { schStatus2.textContent = `✅ Record xong! ${sizeMB}MB (${durationMin}p)`; schStatus2.className = 'sch-status active'; }
713
+ const schCancelBtn2 = document.getElementById('vtv-sch-cancel-btn');
714
+ if (schCancelBtn2) schCancelBtn2.disabled = true;
715
+ recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
716
  };
717
+ _rec.recorder.onerror = e => {
718
+ _rec.isRecording = false;
719
+ _rec.scheduleMode = false;
720
+ video.muted = wasMuted; video.volume = wasVolume;
721
+ if (schStatus) { schStatus.textContent = '❌ Lỗi: ' + (e.error?.message||'?'); schStatus.className = 'sch-status error'; }
722
+ recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
 
 
 
 
 
 
 
 
 
 
 
723
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
724
 
725
+ _rec.recorder.start(1000);
 
 
 
 
 
 
 
726
 
727
+ if (startTime > 0) {
728
+ video.currentTime = startTime;
729
+ }
 
 
 
 
 
 
 
730
 
731
+ const btn = document.getElementById('vtv-rec-btn');
732
+ 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'; }
733
+ recStatus('🔴 ĐANG RECORD (lên lịch)...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
 
 
734
 
735
+ _rec._scheduleTimer = setTimeout(() => {
736
+ if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') {
737
+ _rec.recorder.stop();
738
+ }
739
+ }, durationMs + 1000);
740
 
741
+ const updateInterval = setInterval(() => {
742
+ if (!_rec.isRecording || !_rec.scheduleMode) {
743
+ clearInterval(updateInterval);
744
+ return;
745
+ }
746
+ const elapsed = Math.round((new Date() - now) / 1000);
747
+ const remaining = Math.round((targetEnd - new Date()) / 1000);
748
+ if (remaining > 0) {
749
+ recStatus(`🔴 RECORDING... Đã record ${fmtSec(elapsed)}, còn lại ${fmtSec(remaining)}`, 'recording');
750
+ }
751
+ }, 10000);
752
+ };
753
 
754
+ window._vtvSchCancel = function() {
755
+ if (_rec._scheduleTimer) { clearTimeout(_rec._scheduleTimer); _rec._scheduleTimer = null; }
756
+ if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') {
757
+ _rec.recorder.stop();
758
+ }
759
+ _rec.scheduleMode = false;
760
+ const schStatus = document.getElementById('vtv-sch-status');
761
+ if (schStatus) { schStatus.textContent = 'Đã hủy lịch record.'; schStatus.className = 'sch-status'; }
762
+ const schCancelBtn = document.getElementById('vtv-sch-cancel-btn');
763
+ if (schCancelBtn) schCancelBtn.disabled = true;
 
 
 
 
 
764
  };
765
 
766
+ window._vtvRecGenAITitle = async function() {
767
+ if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
768
+ const btn = document.getElementById('vtv-rec-ai-btn');
769
+ const input = document.getElementById('vtv-rec-title-input');
770
+ if (!btn || !input) return;
 
 
 
 
 
 
 
 
771
 
772
+ btn.disabled = true;
773
+ btn.textContent = '⏳ Đang tạo...';
 
 
 
774
 
775
+ try {
776
+ const ch = CHANNELS.find(c => c.id === _currentCh);
777
+ const channelName = ch ? ch.name : (_currentCh || 'VTV');
778
+ const title = await generateAITitle(_rec.blob, channelName);
779
+ input.value = title;
780
+ recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
781
+ } catch(e) {
782
+ recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
783
+ } finally {
784
+ btn.disabled = false;
785
+ btn.textContent = '🤖 AI tạo tiêu đề';
786
+ }
787
+ };
788
+
789
+ window._vtvRecDownload = function() {
790
+ if (!_rec.blob) return;
791
+ const title = document.getElementById('vtv-rec-title