Spaces:
Running
Running
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
- static/yt_live.js +133 -138
static/yt_live.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
// === VNEWS — VTV LIVE + Inline Recorder
|
| 2 |
-
// Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
|
| 3 |
-
//
|
| 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 = '
|
|
|
|
| 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)
|
| 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">
|
| 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 |
-
|
| 591 |
-
|
| 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.
|
| 608 |
-
|
| 609 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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')){
|