bep40 commited on
Commit
40b4777
·
verified ·
1 Parent(s): a5c5040

Fix VTV player v8: try direct URLs first (client fetches through VPN), fall back to proxy on CORS error. Fix FPTPlay referer headers in xhrSetup.

Browse files
Files changed (1) hide show
  1. static/yt_live.js +179 -583
static/yt_live.js CHANGED
@@ -1,5 +1,6 @@
1
- // === VNEWS — VTV LIVE + Inline Recorder v7 ===
2
- // Fixes: proxy ALL m3u8 (CORS), iOS webkit-playsinline, HLS config, robust error recovery
 
3
  // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
4
 
5
  (function(){
@@ -15,8 +16,6 @@
15
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
16
  ];
17
  const DEFAULT_CHANNEL = 'vtv6';
18
- // ALL external m3u8 domains go through proxy to avoid CORS
19
- const DOMAINS = /\.(?:fptplay\.net|xemtv\.net|vtvdigital\.vn|canthotv\.vn|v\.tvn\.vn|cloudfront\.net|akamaized\.net|googlevideo\.com)/;
20
  const STREAMS = {};
21
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
22
  let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
@@ -172,7 +171,6 @@
172
  if (el) el.style.display = 'none';
173
  }
174
 
175
- // ===== UPLOAD TO WALL (Short AI) =====
176
  function uploadToWall(blob, title, source) {
177
  const formData = new FormData();
178
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
@@ -198,7 +196,6 @@
198
  });
199
  }
200
 
201
- // ===== AI TITLE GENERATION =====
202
  async function generateAITitle(blob, channelName) {
203
  return new Promise((resolve) => {
204
  const video = document.createElement('video');
@@ -231,544 +228,150 @@
231
  function buildInlineRecorder() {
232
  const controls = document.querySelector('.vtv-controls');
233
  if (!controls || document.getElementById('vtv-rec-btn')) return;
234
-
235
  const recBtn = document.createElement('button');
236
- recBtn.id = 'vtv-rec-btn';
237
- recBtn.className = 'vtv-rec-btn';
238
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
239
  recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
240
  recBtn.onclick = toggleRecPanel;
241
  controls.appendChild(recBtn);
242
-
243
  const inlineRec = document.createElement('div');
244
- inlineRec.id = 'vtv-inline-rec';
245
- inlineRec.className = 'vtv-inline-rec';
246
- inlineRec.innerHTML = `
247
- <div class="rec-time">
248
- <span id="vtv-rec-time-start">00:00</span>
249
- <span id="vtv-rec-time-dur">Duration: 0s</span>
250
- <span id="vtv-rec-time-end">01:00</span>
251
- </div>
252
- <div class="rec-bar" id="vtv-rec-bar">
253
- <div class="rec-progress" id="vtv-rec-progress"></div>
254
- <div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
255
- <div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
256
- </div>
257
- <div class="rec-controls">
258
- <button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button>
259
- <button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button>
260
- <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
261
- <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
262
- </div>
263
- <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
264
- <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
265
- `;
266
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
267
-
268
  const recPanel = document.createElement('div');
269
- recPanel.id = 'vtv-rec-panel';
270
- recPanel.className = 'vtv-rec-panel';
271
- recPanel.innerHTML = `
272
- <div class="rec-panel-title">🎬 Đã record xong!</div>
273
- <div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
274
- <video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
275
- </div>
276
- <input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)">
277
- <div class="rec-ai-title-row">
278
- <button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button>
279
- </div>
280
- <div class="rec-ratio-row">
281
- <button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
282
- <button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
283
- <button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button>
284
- </div>
285
- <div class="rec-actions">
286
- <button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button>
287
- <button class="rec-share" onclick="window._vtvRecShare()">🎬 Tạo Short AI</button>
288
- </div>
289
- <div class="rec-proc" id="vtv-rec-proc">
290
- <div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div>
291
- <span id="vtv-rec-proc-text">Đang xử lý...</span>
292
- </div>
293
- `;
294
  inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
295
-
296
- document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
297
- if (!_rec.isRecording) handleRecBarClick(e);
298
- });
299
-
300
  document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
301
  document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
302
  document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
303
  document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
304
  }
305
 
306
- // Global drag
307
- document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
308
- document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
309
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
310
  document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
311
 
312
- function dragMarker(clientX) {
313
- const bar = document.getElementById('vtv-rec-bar');
314
- if (!bar) return;
315
- const rect = bar.getBoundingClientRect();
316
- const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
317
- const time = pct * 60;
318
- if (_rec._dragMarker === 'start') {
319
- if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
320
- _rec.startTime = time;
321
- const mk = document.getElementById('vtv-rec-mk-start');
322
- mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
323
- document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
324
- } else {
325
- if (_rec.startTime !== null && time <= _rec.startTime + 1) return;
326
- _rec.endTime = time;
327
- const mk = document.getElementById('vtv-rec-mk-end');
328
- mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
329
- document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
330
- }
331
- updateRecBar();
332
- }
333
-
334
- function handleRecBarClick(e) {
335
- const video = document.getElementById('vtv-player');
336
- if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
337
- const bar = document.getElementById('vtv-rec-bar');
338
- const rect = bar.getBoundingClientRect();
339
- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
340
- const time = pct * 60;
341
- if (_rec.startTime === null) {
342
- _rec.startTime = time;
343
- const mk = document.getElementById('vtv-rec-mk-start');
344
- mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
345
- document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
346
- document.getElementById('vtv-rec-set-end').disabled = false;
347
- recStatus('Điểm đầu: ' + fmtSec(time) + '. Chọn điểm cuối.', '');
348
- } else if (_rec.endTime === null) {
349
- if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
350
- const dur = time - _rec.startTime;
351
- if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
352
- _rec.endTime = time;
353
- const mk = document.getElementById('vtv-rec-mk-end');
354
- mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
355
- document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
356
- updateRecBar();
357
- recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
358
- document.getElementById('vtv-rec-go').disabled = false;
359
- }
360
- }
361
-
362
- function updateRecBar() {
363
- const s = _rec.startTime || 0, e = _rec.endTime || 60;
364
- const dur = e - s;
365
- document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
366
- document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
367
- document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
368
- }
369
-
370
- // ===== RECORDER CONTROLS =====
371
- window._vtvRecSetStart = function() {
372
- const video = document.getElementById('vtv-player');
373
- if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
374
- _rec.startTime = video.currentTime || 0;
375
- const pct = _rec.startTime / 60 * 100;
376
- const mk = document.getElementById('vtv-rec-mk-start');
377
- mk.style.display = 'block'; mk.style.left = pct + '%';
378
- document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
379
- document.getElementById('vtv-rec-set-end').disabled = false;
380
- recStatus('Điểm đầu: ' + fmtSec(_rec.startTime), '');
381
- };
382
-
383
- window._vtvRecSetEnd = function() {
384
- if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
385
- const video = document.getElementById('vtv-player');
386
- if (!video) return;
387
- const cur = video.currentTime || 0;
388
- if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
389
- const dur = cur - _rec.startTime;
390
- if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
391
- _rec.endTime = cur;
392
- const pct = cur / 60 * 100;
393
- const mk = document.getElementById('vtv-rec-mk-end');
394
- mk.style.display = 'block'; mk.style.left = pct + '%';
395
- document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
396
- updateRecBar();
397
- recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
398
- document.getElementById('vtv-rec-go').disabled = false;
399
- };
400
-
401
- window._vtvRecReset = function() {
402
- _rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
403
- if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
404
- clearTimeout(_rec._recTimer);
405
- document.getElementById('vtv-rec-mk-start').style.display = 'none';
406
- document.getElementById('vtv-rec-mk-end').style.display = 'none';
407
- document.getElementById('vtv-rec-progress').style.width = '0%';
408
- document.getElementById('vtv-rec-time-start').textContent = '00:00';
409
- document.getElementById('vtv-rec-time-end').textContent = '01:00';
410
- document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
411
- document.getElementById('vtv-rec-set-start').disabled = false;
412
- document.getElementById('vtv-rec-set-end').disabled = true;
413
- document.getElementById('vtv-rec-go').disabled = true;
414
- document.getElementById('vtv-rec-panel').classList.remove('show');
415
- document.getElementById('vtv-rec-title-input').value = '';
416
- recStatus('Đã reset.', '');
417
- const btn = document.getElementById('vtv-rec-btn');
418
- if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
419
- };
420
-
421
- window._vtvRecSetRatio = function(ratio) {
422
- _rec.ratio = ratio;
423
- document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
424
- };
425
-
426
- // ===== RECORD — captureStream with audio (unmute during record) =====
427
- window._vtvRecGo = function() {
428
- if (_rec.startTime === null || _rec.endTime === null) return;
429
- if (_rec.isRecording) {
430
- if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
431
- _rec.isRecording = false;
432
- clearTimeout(_rec._recTimer);
433
- const btn = document.getElementById('vtv-rec-btn');
434
- if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
435
- return;
436
- }
437
-
438
- const video = document.getElementById('vtv-player');
439
- if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
440
-
441
- _rec.chunks = [];
442
- _rec.isRecording = true;
443
-
444
- // CRITICAL: Unmute video so captureStream includes audio tracks
445
- const wasMuted = video.muted;
446
- video.muted = false;
447
- const wasVolume = video.volume;
448
- video.volume = 0.3;
449
-
450
- let stream;
451
- try {
452
- stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
453
- if (!stream) throw new Error('captureStream not supported');
454
- } catch(e) {
455
- video.muted = wasMuted; video.volume = wasVolume;
456
- recStatus('❌ ' + e.message, 'err');
457
- _rec.isRecording = false;
458
- return;
459
- }
460
-
461
- const audioTracks = stream.getAudioTracks();
462
- console.log('[VTV Rec] Audio tracks:', audioTracks.length, 'Video tracks:', stream.getVideoTracks().length);
463
-
464
- let mimeType = 'video/webm;codecs=vp9,opus';
465
- if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
466
- if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
467
-
468
- try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
469
- catch(e) {
470
- video.muted = wasMuted; video.volume = wasVolume;
471
- recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
472
- _rec.isRecording = false;
473
- return;
474
- }
475
-
476
- _rec.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _rec.chunks.push(e.data); };
477
- _rec.recorder.onstop = function() {
478
- _rec.isRecording = false;
479
- video.muted = wasMuted;
480
- video.volume = wasVolume;
481
- _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
482
- const btn = document.getElementById('vtv-rec-btn');
483
- if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
484
- document.getElementById('vtv-rec-panel').classList.add('show');
485
- const pv = document.getElementById('vtv-rec-preview-vid');
486
- pv.src = URL.createObjectURL(_rec.blob);
487
- document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
488
- const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
489
- recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
490
- };
491
- _rec.recorder.onerror = e => {
492
- _rec.isRecording = false;
493
- video.muted = wasMuted; video.volume = wasVolume;
494
- recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
495
- };
496
-
497
- _rec.recorder.start(500);
498
- video.currentTime = _rec.startTime;
499
-
500
- const btn = document.getElementById('vtv-rec-btn');
501
- 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'; }
502
- recStatus('🔴 ĐANG RECORD...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
503
-
504
- const dur = (_rec.endTime - _rec.startTime) * 1000;
505
- _rec._recTimer = setTimeout(() => {
506
- if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
507
- }, dur + 500);
508
- };
509
-
510
- // ===== AI TITLE =====
511
- window._vtvRecGenAITitle = async function() {
512
- if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
513
- const btn = document.getElementById('vtv-rec-ai-btn');
514
- const input = document.getElementById('vtv-rec-title-input');
515
- if (!btn || !input) return;
516
-
517
- btn.disabled = true;
518
- btn.textContent = '⏳ Đang tạo...';
519
-
520
- try {
521
- const ch = CHANNELS.find(c => c.id === _currentCh);
522
- const channelName = ch ? ch.name : (_currentCh || 'VTV');
523
- const title = await generateAITitle(_rec.blob, channelName);
524
- input.value = title;
525
- recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
526
- } catch(e) {
527
- recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
528
- } finally {
529
- btn.disabled = false;
530
- btn.textContent = '🤖 AI tạo tiêu đề';
531
- }
532
- };
533
-
534
- // ===== DOWNLOAD =====
535
- window._vtvRecDownload = function() {
536
- if (!_rec.blob) return;
537
- const title = document.getElementById('vtv-rec-title-input')?.value || '';
538
- if (_rec.ratio === 'original') {
539
- downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
540
- } else {
541
- processRatio(_rec.blob, _rec.ratio, 'download', title);
542
- }
543
- };
544
-
545
- // ===== SHARE TO SHORT AI =====
546
- window._vtvRecShare = function() {
547
- if (!_rec.blob) return;
548
- const title = document.getElementById('vtv-rec-title-input')?.value || '';
549
- if (_rec.ratio !== 'original') {
550
- processRatio(_rec.blob, _rec.ratio, 'upload', title);
551
- } else {
552
- uploadToWall(_rec.blob, title);
553
- }
554
- };
555
-
556
- // ===== RATIO CROP — v6: reliable render =====
557
- function processRatio(blob, ratio, action, title) {
558
  showProc(`🔄 Đang xử lý ${ratio}...`);
559
-
560
- const video = document.createElement('video');
561
- video.src = URL.createObjectURL(blob);
562
- video.volume = 0;
563
- video.preload = 'auto';
564
- video.playsInline = true;
565
-
566
- function onReady() {
567
- video.removeEventListener('loadedmetadata', onReady);
568
- video.removeEventListener('canplay', onReady);
569
-
570
- const vw = video.videoWidth, vh = video.videoHeight;
571
- if (!vw || !vh) {
572
- hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
573
- URL.revokeObjectURL(video.src); return;
574
- }
575
-
576
- let cw, ch, sx, sy;
577
- if (ratio === '1:1') {
578
- const size = Math.min(vw, vh); cw = ch = size;
579
- sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
580
- } else if (ratio === '9:16') {
581
- ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
582
- sx = Math.floor((vw - cw) / 2); sy = 0;
583
- } else { cw = vw; ch = vh; sx = sy = 0; }
584
-
585
  showProc(`🔄 Render ${cw}x${ch}...`);
586
-
587
- const canvas = document.createElement('canvas');
588
- canvas.width = cw; canvas.height = ch;
589
- const ctx = canvas.getContext('2d');
590
- const canvasStream = canvas.captureStream(30);
591
-
592
- let recordStream = canvasStream;
593
- const audioVideo = document.createElement('video');
594
- audioVideo.src = video.src;
595
- audioVideo.muted = true;
596
- audioVideo.volume = 0;
597
- audioVideo.preload = 'auto';
598
- audioVideo.playsInline = true;
599
-
600
- audioVideo.onloadedmetadata = function() {
601
- try {
602
- const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
603
- if (aStr) {
604
- const tracks = aStr.getAudioTracks();
605
- if (tracks.length > 0) {
606
- recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
607
- }
608
- }
609
- } catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
610
- };
611
-
612
- const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
613
- ? 'video/webm;codecs=vp9,opus'
614
- : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
615
- ? 'video/webm;codecs=vp8,opus' : 'video/webm';
616
-
617
- let rec;
618
- try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
619
- catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(video.src); return; }
620
-
621
- const chunks = [];
622
- rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
623
- rec.onstop = () => {
624
- hideProc(); URL.revokeObjectURL(video.src);
625
- if (chunks.length === 0) {
626
- recStatus('❌ Render thất bại. Thử lại hoặc chọn "Gốc".', 'err');
627
- return;
628
- }
629
- const out = new Blob(chunks, {type: 'video/webm'});
630
- if (action === 'download') {
631
- downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
632
- } else {
633
- uploadToWall(out, title);
634
- }
635
- };
636
- rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(video.src); };
637
-
638
- rec.start(200);
639
-
640
- audioVideo.play().catch(() => {});
641
-
642
- video.play().then(() => {
643
- let finished = false;
644
- function finish() {
645
- if (finished) return;
646
- finished = true;
647
- try { rec.requestData(); } catch(e) {}
648
- setTimeout(() => {
649
- if (rec.state !== 'inactive') rec.stop();
650
- try { audioVideo.pause(); } catch(e) {}
651
- }, 300);
652
- }
653
- video.onended = finish;
654
- audioVideo.onended = finish;
655
-
656
- function draw() {
657
- if (video.paused || video.ended) return;
658
- ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
659
- requestAnimationFrame(draw);
660
- }
661
- draw();
662
- }).catch(e => {
663
- hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
664
- URL.revokeObjectURL(video.src);
665
- });
666
  }
667
-
668
- video.onerror = function() {
669
- hideProc(); recStatus('❌ Không đọc được video.', 'err');
670
- URL.revokeObjectURL(video.src);
671
- };
672
-
673
- video.addEventListener('loadedmetadata', onReady);
674
- video.addEventListener('canplay', onReady);
675
  }
676
 
677
- // ===== TOGGLE REC PANEL =====
678
- function toggleRecPanel() {
679
- const panel = document.getElementById('vtv-inline-rec');
680
- if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
681
- _rec.active = !_rec.active;
682
- panel.classList.toggle('show', _rec.active);
683
- const btn = document.getElementById('vtv-rec-btn');
684
- if (btn) {
685
- if (_rec.active) {
686
- btn.style.background = '#3a1a1a';
687
- btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
688
- } else {
689
- btn.style.background = '';
690
- btn.innerHTML = '<svg viewBox="0 0 24"><circle cx="12" cy="12" r="8"/></svg> Record';
691
- document.getElementById('vtv-rec-panel').classList.remove('show');
692
- }
693
- }
694
  }
695
 
696
- // ===== PIN BUTTON =====
697
  function setupPinButton(){
698
- const block = document.getElementById('vtv-block');
699
- if(!block) return;
700
- const header = block.querySelector('.vtv-head');
701
- if(!header || document.getElementById('vtv-pin-btn')) return;
702
- const btn = document.createElement('button');
703
- btn.id = 'vtv-pin-btn'; btn.className = 'vtv-pin-btn';
704
- btn.innerHTML = '📌 Ghim';
705
- btn.title = 'Ghim VTV cố định trên đầu trang';
706
- btn.onclick = function(e){
707
- e.stopPropagation(); _vtvPinned = !_vtvPinned;
708
- if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
709
- else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
710
- };
711
- header.style.position = 'relative';
712
- header.appendChild(btn);
713
  }
714
 
715
- // ===== MINI PLAYER =====
716
  function createMiniPlayer(){
717
- if(document.getElementById('vtv-mini')) return;
718
- const mini = document.createElement('div');
719
- mini.className = 'vtv-mini'; mini.id = 'vtv-mini';
720
- mini.innerHTML = `<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
721
- <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>
722
- <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>
723
- <div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
724
  document.body.appendChild(mini);
725
- document.getElementById('vtv-mini-pip').onclick = async function(){
726
- const v = document.getElementById('vtv-mini-vid');
727
- try{ if(document.pictureInPictureElement === v) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); }catch(e){}
728
- };
729
- document.getElementById('vtv-mini-expand').onclick = function(){ closeMiniPlayer(); if(typeof switchCat==='function') switchCat('home'); };
730
- document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
731
- document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
732
- document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
733
  }
734
- 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; }
 
735
  function activateMiniPlayer(){
736
- if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
737
- const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
738
- if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:30}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
739
- else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
740
- const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
741
- 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(()=>{});
742
- mini.classList.add('show'); mini.classList.remove('hidden');
743
  }
744
 
745
- // ===== PROXY URL helper =====
746
- function proxyUrl(url) {
747
- // Always proxy external m3u8 URLs to avoid CORS on segments
748
- if (!url) return url;
749
- try {
750
- const u = new URL(url);
751
- // Don't proxy same-origin or already-proxied URLs
752
- if (u.origin === location.origin) return url;
753
- if (url.startsWith('/api/proxy/')) return url;
754
- return '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(url);
755
- } catch(e) {
756
- return url;
757
- }
758
- }
759
 
760
  // ===== MAIN PLAYER =====
761
  async function loadAllStreams(){
762
- if(_loading) return; _loading=true;
763
- const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
764
- try{const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)}); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){STREAMS[ch.id]=[proxyUrl(i.stream_url)];} else STREAMS[ch.id]=[];});}}catch(e){ console.warn('loadAllStreams error:', e);}
765
- 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+' ✕';}}});
766
- _loading=false; _streamsLoaded=true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
  }
768
 
769
  function buildBlock(){
770
- const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
771
- let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
772
  w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
773
  '<div class="vtv-tabs">'+tabs+'</div>'+
774
  '<div class="vtv-frame" id="vtv-frame">'+
@@ -782,48 +385,36 @@
782
  }
783
 
784
  async function loadEpg(chId){
785
- const el=document.getElementById('vtv-epg-list'); if(!el) return;
786
  el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
787
- try{const r=await fetch('/api/vtv/epg/'+chId,{signal:AbortSignal.timeout(10000)}); if(!r.ok) throw new Error(); const d=await r.json(); const p=d.programs||[]; if(!p.length){el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch</div>';return;} let h=''; p.forEach(x=>{const n=x.now?' now':''; h+='<div class="vtv-epg-item'+n+'"><span class="t">'+(x.time||'')+(x.end_time?'-'+x.end_time:'')+'</span> <span class="n">'+(x.title||'')+'</span></div>';}); el.innerHTML=h; const ni=el.querySelector('.vtv-epg-item.now'); if(ni) ni.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});}catch(e){el.innerHTML='<div class="vtv-epg-empty">Không tải được lịch</div>';}
788
  }
789
 
790
- 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';}};
791
- 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){}};
792
 
793
  function pinBlock(){
794
- const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
795
- h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
796
  setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
797
  if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
798
  }
799
 
800
- window._vtvRetry=function(){
801
- if(_currentCh)window._vtvPlay(_currentCh);
802
- else loadAllStreams().then(()=>{if(DEFAULT_CHANNEL)window._vtvPlay(DEFAULT_CHANNEL);});
803
- };
804
 
805
- // Ensure video element is ready for playback on iOS
806
- function ensureVideoReady(video) {
807
- // iOS requires these attributes for inline playback
808
- video.setAttribute('playsinline', '');
809
- video.setAttribute('webkit-playsinline', '');
810
- // Prevent fullscreen on iOS
811
- video.setAttribute('controlslist', 'nodownload');
812
- // Ensure black background while loading
813
- video.style.backgroundColor = '#000';
814
  }
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
  ensureVideoReady(video);
822
- // Show video BEFORE HLS attaches — prevents black screen on mobile
823
- video.style.display='block';
824
- video.style.visibility='hidden';
825
- video.style.opacity='0';
826
- errEl.style.display='none'; loadEl.style.display='flex';
827
  loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
828
  if(_hls){_hls.destroy();_hls=null;}
829
  const urls=STREAMS[chId]||[];
@@ -836,18 +427,26 @@
836
  function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
837
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
838
  const src=urls[idx];
839
- loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
 
840
 
841
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
842
- // Fixed HLS config for stable playback
843
  const hls=new Hls({
844
  enableWorker:true,
845
  lowLatencyMode:false,
846
- startLevel:0, // Start with lowest quality, auto-upgrade
847
  capLevelToPlayerSize:true,
848
- maxBufferLength:30, // Larger buffer for stability
849
  maxMaxBufferLength:120,
850
  backBufferLength:90,
 
 
 
 
 
 
 
 
851
  });
852
  _hls=hls;
853
  hls.loadSource(src);
@@ -855,90 +454,87 @@
855
 
856
  hls.on(Hls.Events.MANIFEST_PARSED,()=>{
857
  loadEl.style.display='none';
858
- // Make video visible now that HLS is ready
859
  video.style.visibility='visible';
860
  video.style.opacity='1';
861
- // Play muted (required for mobile autoplay)
862
- video.play().then(()=>{
863
- console.log('[VTV] Playing:', name);
864
- }).catch(e=>{
865
- console.warn('[VTV] Play failed:', e.message);
866
- // Try muted autoplay
867
- video.muted=true;
868
- video.play().catch(()=>{});
869
  });
870
  });
871
 
872
  hls.on(Hls.Events.FRAG_LOADED,()=>{
873
- if(video.style.visibility === 'hidden'){
874
- video.style.visibility='visible';
875
- video.style.opacity='1';
876
- loadEl.style.display='none';
877
  }
878
  });
879
 
880
  let retryCount=0;
881
- let fatalError=false;
882
  hls.on(Hls.Events.ERROR,(ev,data)=>{
883
  if(data.fatal){
884
- fatalError=true;
885
  if(data.type===Hls.ErrorTypes.NETWORK_ERROR){
886
  retryCount++;
887
- if(retryCount<=5){
888
- loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối lại '+name+' ('+retryCount+'/5)...';
889
- setTimeout(()=>{
890
- fatalError=false;
891
- hls.startLoad();
892
- }, 2000 * retryCount);
893
  }else{
894
- hls.destroy();
895
- _hls=null;
896
  _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
897
  }
898
  }else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){
899
  try{hls.recoverMediaError();}catch(e){}
900
  }else{
901
- hls.destroy();
902
- _hls=null;
903
  _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
904
  }
905
  }else{
906
- console.warn('HLS non-fatal error:', data.type, data.details);
907
  }
908
  });
909
-
910
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
911
  video.src=src;
912
  video.addEventListener('loadedmetadata',()=>{
913
- loadEl.style.display='none';
914
- video.style.visibility='visible';
915
- video.style.opacity='1';
916
- video.play().catch(()=>{});
917
  },{once:true});
918
  video.addEventListener('playing',()=>{
919
- loadEl.style.display='none';
920
- video.style.visibility='visible';
921
- video.style.opacity='1';
922
- },{once:true});
923
- video.addEventListener('error',()=>{
924
- _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
925
  },{once:true});
 
926
  }else{
927
- loadEl.style.display='none';
928
- errEl.style.display='flex';
929
- errMsg.textContent='Không hỗ trợ HLS';
930
  }
931
  }
932
 
933
  // ===== NAV HOOKS =====
934
  const _origShowView=window.showView;
935
- 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);};
936
- 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);};}
937
- 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);};}
938
-
939
- const _origLoadHome=window.loadHome;
940
- if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
941
- 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;};
942
- window.loadHome.__vtvWrapped=true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
943
  }
 
 
 
 
 
 
944
  })();
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v8 ===
2
+ // KEY CHANGE: Try DIRECT stream URLs first (client fetches through VPN), fall back to proxy only on CORS error
3
+ // Fixes: FPTPlay URLs updated, VTVGO failover, canthotv fallback, AES-128 key URI rewriting
4
  // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
5
 
6
  (function(){
 
16
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
17
  ];
18
  const DEFAULT_CHANNEL = 'vtv6';
 
 
19
  const STREAMS = {};
20
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
21
  let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
 
171
  if (el) el.style.display = 'none';
172
  }
173
 
 
174
  function uploadToWall(blob, title, source) {
175
  const formData = new FormData();
176
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
 
196
  });
197
  }
198
 
 
199
  async function generateAITitle(blob, channelName) {
200
  return new Promise((resolve) => {
201
  const video = document.createElement('video');
 
228
  function buildInlineRecorder() {
229
  const controls = document.querySelector('.vtv-controls');
230
  if (!controls || document.getElementById('vtv-rec-btn')) return;
 
231
  const recBtn = document.createElement('button');
232
+ recBtn.id = 'vtv-rec-btn'; recBtn.className = 'vtv-rec-btn';
 
233
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
234
  recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
235
  recBtn.onclick = toggleRecPanel;
236
  controls.appendChild(recBtn);
 
237
  const inlineRec = document.createElement('div');
238
+ inlineRec.id = 'vtv-inline-rec'; inlineRec.className = 'vtv-inline-rec';
239
+ inlineRec.innerHTML = `<div class="rec-time"><span id="vtv-rec-time-start">00:00</span><span id="vtv-rec-time-dur">Duration: 0s</span><span id="vtv-rec-time-end">01:00</span></div><div class="rec-bar" id="vtv-rec-bar"><div class="rec-progress" id="vtv-rec-progress"></div><div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div><div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div></div><div class="rec-controls"><button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button><button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button><button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button><button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button></div><div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div><div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
 
241
  const recPanel = document.createElement('div');
242
+ recPanel.id = 'vtv-rec-panel'; recPanel.className = 'vtv-rec-panel';
243
+ recPanel.innerHTML = `<div class="rec-panel-title">🎬 Đã record xong!</div><div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none"><video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video></div><input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)"><div class="rec-ai-title-row"><button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button></div><div class="rec-ratio-row"><button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button><button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button><button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button></div><div class="rec-actions"><button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button><button class="rec-share" onclick="window._vtvRecShare()">🎬 Tạo Short AI</button></div><div class="rec-proc" id="vtv-rec-proc"><div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div><span id="vtv-rec-proc-text">Đang xử lý...</span></div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
245
+ document.getElementById('vtv-rec-bar').addEventListener('click', function(e){ if(!_rec.isRecording) handleRecBarClick(e); });
 
 
 
 
246
  document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
247
  document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
248
  document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
249
  document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
250
  }
251
 
252
+ document.addEventListener('mousemove', function(e){ if(_rec._dragMarker) dragMarker(e.clientX); });
253
+ document.addEventListener('touchmove', function(e){ if(_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
 
254
  document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
255
  document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
256
 
257
+ function dragMarker(clientX){const bar=document.getElementById('vtv-rec-bar');if(!bar)return;const rect=bar.getBoundingClientRect();const pct=Math.max(0,Math.min(1,(clientX-rect.left)/rect.width));const time=pct*60;if(_rec._dragMarker==='start'){if(_rec.endTime!==null&&time>=_rec.endTime-1)return;_rec.startTime=time;const mk=document.getElementById('vtv-rec-mk-start');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-start').textContent=fmtSec(time);}else{if(_rec.startTime!==null&&time<=_rec.startTime+1)return;_rec.endTime=time;const mk=document.getElementById('vtv-rec-mk-end');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-end').textContent=fmtSec(time);}updateRecBar();}
258
+ function handleRecBarClick(e){const video=document.getElementById('vtv-player');if(!video||!video.src){recStatus('⚠️ Chưa có stream!');return;}const bar=document.getElementById('vtv-rec-bar');const rect=bar.getBoundingClientRect();const pct=Math.max(0,Math.min(1,(e.clientX-rect.left)/rect.width));const time=pct*60;if(_rec.startTime===null){_rec.startTime=time;const mk=document.getElementById('vtv-rec-mk-start');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-start').textContent=fmtSec(time);document.getElementById('vtv-rec-set-end').disabled=false;recStatus('Điểm đầu: '+fmtSec(time)+'. Chọn điểm cuối.','');}else if(_rec.endTime===null){if(time<=_rec.startTime){recStatus('⚠️ Điểm cuối phải sau điểm đầu!','err');return;}const dur=time-_rec.startTime;if(dur>60){recStatus('⚠️ Tối đa 60s!','err');return;}_rec.endTime=time;const mk=document.getElementById('vtv-rec-mk-end');mk.style.display='block';mk.style.left=(pct*100)+'%';document.getElementById('vtv-rec-time-end').textContent=fmtSec(time);updateRecBar();recStatus('✅ '+Math.round(dur)+'s. Nhấn Record!','ok');document.getElementById('vtv-rec-go').disabled=false;}}
259
+ function updateRecBar(){const s=_rec.startTime||0,e=_rec.endTime||60;const dur=e-s;document.getElementById('vtv-rec-progress').style.left=(s/60*100)+'%';document.getElementById('vtv-rec-progress').style.width=(dur/60*100)+'%';document.getElementById('vtv-rec-time-dur').textContent='Duration: '+Math.round(dur)+'s';}
260
+ window._vtvRecSetStart=function(){const video=document.getElementById('vtv-player');if(!video||!video.src){recStatus('⚠️ Chưa có stream!','err');return;}_rec.startTime=video.currentTime||0;const pct=_rec.startTime/60*100;const mk=document.getElementById('vtv-rec-mk-start');mk.style.display='block';mk.style.left=pct+'%';document.getElementById('vtv-rec-time-start').textContent=fmtSec(_rec.startTime);document.getElementById('vtv-rec-set-end').disabled=false;recStatus('Điểm đầu: '+fmtSec(_rec.startTime),'');};
261
+ window._vtvRecSetEnd=function(){if(_rec.startTime===null){recStatus('⚠️ Chọn điểm đầu trước!','err');return;}const video=document.getElementById('vtv-player');if(!video)return;const cur=video.currentTime||0;if(cur<=_rec.startTime){recStatus('⚠️ Điểm cuối phải sau điểm đầu!','err');return;}const dur=cur-_rec.startTime;if(dur>60){recStatus('⚠️ Tối đa 60s!','err');return;}_rec.endTime=cur;const pct=cur/60*100;const mk=document.getElementById('vtv-rec-mk-end');mk.style.display='block';mk.style.left=pct+'%';document.getElementById('vtv-rec-time-end').textContent=fmtSec(cur);updateRecBar();recStatus('✅ '+Math.round(dur)+'s. Nhấn Record!','ok');document.getElementById('vtv-rec-go').disabled=false;};
262
+ window._vtvRecReset=function(){_rec.startTime=null;_rec.endTime=null;_rec.isRecording=false;_rec.blob=null;if(_rec.recorder&&_rec.recorder.state!=='inactive')_rec.recorder.stop();clearTimeout(_rec._recTimer);document.getElementById('vtv-rec-mk-start').style.display='none';document.getElementById('vtv-rec-mk-end').style.display='none';document.getElementById('vtv-rec-progress').style.width='0%';document.getElementById('vtv-rec-time-start').textContent='00:00';document.getElementById('vtv-rec-time-end').textContent='01:00';document.getElementById('vtv-rec-time-dur').textContent='Duration: 0s';document.getElementById('vtv-rec-set-start').disabled=false;document.getElementById('vtv-rec-set-end').disabled=true;document.getElementById('vtv-rec-go').disabled=true;document.getElementById('vtv-rec-panel').classList.remove('show');document.getElementById('vtv-rec-title-input').value='';recStatus('Đã reset.','');const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.remove('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';}};
263
+ window._vtvRecSetRatio=function(ratio){_rec.ratio=ratio;document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b=>b.classList.toggle('active',b.dataset.ratio===ratio));};
264
+ window._vtvRecGo=function(){if(_rec.startTime===null||_rec.endTime===null)return;if(_rec.isRecording){if(_rec.recorder&&_rec.recorder.state!=='inactive')_rec.recorder.stop();_rec.isRecording=false;clearTimeout(_rec._recTimer);const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.remove('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';}return;}const video=document.getElementById('vtv-player');if(!video){recStatus('⚠️ Không tìm thấy video!','err');return;}_rec.chunks=[];_rec.isRecording=true;const wasMuted=video.muted;video.muted=false;const wasVolume=video.volume;video.volume=0.3;let stream;try{stream=video.captureStream?video.captureStream():video.mozCaptureStream();if(!stream)throw new Error('captureStream not supported');}catch(e){video.muted=wasMuted;video.volume=wasVolume;recStatus('❌ '+e.message,'err');_rec.isRecording=false;return;}const audioTracks=stream.getAudioTracks();console.log('[VTV Rec] Audio tracks:',audioTracks.length,'Video tracks:',stream.getVideoTracks().length);let mimeType='video/webm;codecs=vp9,opus';if(!MediaRecorder.isTypeSupported(mimeType))mimeType='video/webm;codecs=vp8,opus';if(!MediaRecorder.isTypeSupported(mimeType))mimeType='video/webm';try{_rec.recorder=new MediaRecorder(stream,{mimeType});}catch(e){video.muted=wasMuted;video.volume=wasVolume;recStatus('❌ Lỗi MediaRecorder: '+e.message,'err');_rec.isRecording=false;return;}_rec.recorder.ondataavailable=e=>{if(e.data&&e.data.size>0)_rec.chunks.push(e.data);};_rec.recorder.onstop=function(){_rec.isRecording=false;video.muted=wasMuted;video.volume=wasVolume;_rec.blob=new Blob(_rec.chunks,{type:'video/webm'});const btn=document.getElementById('vtv-rec-btn');if(btn){btn.classList.remove('recording');btn.innerHTML='<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';}document.getElementById('vtv-rec-panel').classList.add('show');const pv=document.getElementById('vtv-rec-preview-vid');pv.src=URL.createObjectURL(_rec.blob);document.getElementById('vtv-rec-preview-wrap').style.display='block';const sizeMB=(_rec.blob.size/1024/1024).toFixed(1);recStatus('✅ Record xong! '+sizeMB+'MB'+(audioTracks.length>0?' (có audio)':' (video only)'),'ok');};_rec.recorder.onerror=e=>{_rec.isRecording=false;video.muted=wasMuted;video.volume=wasVolume;recStatus('❌ Lỗi: '+(e.error?.message||'?'),'err');};_rec.recorder.start(500);video.currentTime=_rec.startTime;const btn=document.getElementById('vtv-rec-btn');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';}recStatus('🔴 ĐANG RECORD...'+(audioTracks.length>0?' (có audio)':''),'recording');const dur=(_rec.endTime-_rec.startTime)*1000;_rec._recTimer=setTimeout(()=>{if(_rec.isRecording&&_rec.recorder&&_rec.recorder.state!=='inactive')_rec.recorder.stop();},dur+500);};
265
+ window._vtvRecGenAITitle=async function(){if(!_rec.blob){recStatus('⚠️ Chưa record!','err');return;}const btn=document.getElementById('vtv-rec-ai-btn');const input=document.getElementById('vtv-rec-title-input');if(!btn||!input)return;btn.disabled=true;btn.textContent='⏳ Đang tạo...';try{const ch=CHANNELS.find(c=>c.id===_currentCh);const channelName=ch?ch.name:(_currentCh||'VTV');const title=await generateAITitle(_rec.blob,channelName);input.value=title;recStatus('✅ Đã tạo tiêu đề AI: '+title,'ok');}catch(e){recStatus('⚠️ Lỗi AI: '+e.message,'err');}finally{btn.disabled=false;btn.textContent='🤖 AI tạo tiêu đề';}};
266
+ window._vtvRecDownload=function(){if(!_rec.blob)return;const title=document.getElementById('vtv-rec-title-input')?.value||'';if(_rec.ratio==='original'){downloadBlob(_rec.blob,`vtv-${_currentCh}-${Date.now()}.webm`);}else{processRatio(_rec.blob,_rec.ratio,'download',title);}};
267
+ window._vtvRecShare=function(){if(!_rec.blob)return;const title=document.getElementById('vtv-rec-title-input')?.value||'';if(_rec.ratio!=='original'){processRatio(_rec.blob,_rec.ratio,'upload',title);}else{uploadToWall(_rec.blob,title);}};
268
+
269
+ function processRatio(blob,ratio,action,title){
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  showProc(`🔄 Đang xử lý ${ratio}...`);
271
+ const video=document.createElement('video');
272
+ video.src=URL.createObjectURL(blob);video.volume=0;video.preload='auto';video.playsInline=true;
273
+ function onReady(){
274
+ video.removeEventListener('loadedmetadata',onReady);video.removeEventListener('canplay',onReady);
275
+ const vw=video.videoWidth,vh=video.videoHeight;
276
+ if(!vw||!vh){hideProc();recStatus('❌ Video không có kích thước hợp lệ.','err');URL.revokeObjectURL(video.src);return;}
277
+ let cw,ch,sx,sy;
278
+ 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);}
279
+ else if(ratio==='9:16'){ch=vh;cw=Math.min(vw,Math.round(vh*9/16));sx=Math.floor((vw-cw)/2);sy=0;}
280
+ else{cw=vw;ch=vh;sx=sy=0;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  showProc(`🔄 Render ${cw}x${ch}...`);
282
+ const canvas=document.createElement('canvas');canvas.width=cw;canvas.height=ch;
283
+ const ctx=canvas.getContext('2d');
284
+ const canvasStream=canvas.captureStream(30);
285
+ let recordStream=canvasStream;
286
+ const audioVideo=document.createElement('video');
287
+ audioVideo.src=video.src;audioVideo.muted=true;audioVideo.volume=0;audioVideo.preload='auto';audioVideo.playsInline=true;
288
+ 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){console.warn('[VTV Crop] Audio merge failed:',e);}};
289
+ 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';
290
+ let rec;try{rec=new MediaRecorder(recordStream,{mimeType:mime});}catch(e){hideProc();recStatus('❌ Lỗi MediaRecorder: '+e.message,'err');URL.revokeObjectURL(video.src);return;}
291
+ const chunks=[];rec.ondataavailable=e=>{if(e.data&&e.data.size>0)chunks.push(e.data);};
292
+ rec.onstop=()=>{hideProc();URL.revokeObjectURL(video.src);if(chunks.length===0){recStatus('❌ Render thất bại. Thử lại hoặc chọn "Gốc".','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);}};
293
+ rec.onerror=e=>{hideProc();recStatus('❌ Lỗi render: '+(e.error?.message||'?'),'err');URL.revokeObjectURL(video.src);};
294
+ rec.start(200);audioVideo.play().catch(()=>{});
295
+ 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;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(video.src);});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  }
297
+ video.onerror=function(){hideProc();recStatus('❌ Không đọc được video.','err');URL.revokeObjectURL(video.src);};
298
+ video.addEventListener('loadedmetadata',onReady);video.addEventListener('canplay',onReady);
 
 
 
 
 
 
299
  }
300
 
301
+ function toggleRecPanel(){
302
+ const panel=document.getElementById('vtv-inline-rec');
303
+ if(!panel){buildInlineRecorder();setTimeout(toggleRecPanel,200);return;}
304
+ _rec.active=!_rec.active;panel.classList.toggle('show',_rec.active);
305
+ const btn=document.getElementById('vtv-rec-btn');
306
+ if(btn){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';}else{btn.style.background='';btn.innerHTML='<svg viewBox="0 0 24"><circle cx="12" cy="12" r="8"/></svg> Record';document.getElementById('vtv-rec-panel').classList.remove('show');}}
 
 
 
 
 
 
 
 
 
 
 
307
  }
308
 
 
309
  function setupPinButton(){
310
+ const block=document.getElementById('vtv-block');if(!block)return;
311
+ const header=block.querySelector('.vtv-head');if(!header||document.getElementById('vtv-pin-btn'))return;
312
+ const btn=document.createElement('button');btn.id='vtv-pin-btn';btn.className='vtv-pin-btn';btn.innerHTML='📌 Ghim';btn.title='Ghim VTV cố định trên đầu trang';
313
+ btn.onclick=function(e){e.stopPropagation();_vtvPinned=!_vtvPinned;if(_vtvPinned){block.classList.add('vtv-sticky');btn.classList.add('pinned');btn.innerHTML='📌 Đã ghim';}else{block.classList.remove('vtv-sticky');btn.classList.remove('pinned');btn.innerHTML='📌 Ghim';}};
314
+ header.style.position='relative';header.appendChild(btn);
 
 
 
 
 
 
 
 
 
 
315
  }
316
 
 
317
  function createMiniPlayer(){
318
+ if(document.getElementById('vtv-mini'))return;
319
+ const mini=document.createElement('div');mini.className='vtv-mini';mini.id='vtv-mini';
320
+ mini.innerHTML=`<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div><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><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><div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
 
 
 
 
321
  document.body.appendChild(mini);
322
+ document.getElementById('vtv-mini-pip').onclick=async function(){const v=document.getElementById('vtv-mini-vid');try{if(document.pictureInPictureElement===v)await document.exitPictureInPicture();else await v.requestPictureInPicture();}catch(e){}};
323
+ document.getElementById('vtv-mini-expand').onclick=function(){closeMiniPlayer();if(typeof switchCat==='function')switchCat('home');};
324
+ document.getElementById('vtv-mini-hide').onclick=()=>{mini.classList.add('hidden');};
325
+ document.getElementById('vtv-mini-close').onclick=closeMiniPlayer;
326
+ document.getElementById('vtv-mini-peek').onclick=()=>{mini.classList.remove('hidden');};
 
 
 
327
  }
328
+ 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;}
329
+
330
  function activateMiniPlayer(){
331
+ if(!_currentCh)return;createMiniPlayer();_miniActive=true;
332
+ const mini=document.getElementById('vtv-mini');const mv=document.getElementById('vtv-mini-vid');const v=document.getElementById('vtv-player');
333
+ if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:30});h.loadSource(STREAMS[_currentCh][0]);h.attachMedia(mv);mv.play().catch(()=>{});}
334
+ else if(v&&v.src){mv.src=v.src;mv.play().catch(()=>{});}
335
+ const ch=CHANNELS.find(c=>c.id===_currentCh);if(ch)document.getElementById('vtv-mini-ch').textContent=ch.name;
336
+ 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(()=>{});
337
+ mini.classList.add('show');mini.classList.remove('hidden');
338
  }
339
 
340
+ // ===== URL HELPERS =====
341
+ // v8: Try DIRECT URL first, fall back to proxy only on CORS/network error
342
+ function isExternalUrl(url){try{const u=new URL(url);return u.origin!==location.origin;}catch(e){return false;}}
343
+ function proxyUrl(url){if(!url)return url;if(!isExternalUrl(url))return url;if(url.startsWith('/api/proxy/'))return url;return '/api/proxy/m3u8/vtv?url='+encodeURIComponent(url);}
 
 
 
 
 
 
 
 
 
 
344
 
345
  // ===== MAIN PLAYER =====
346
  async function loadAllStreams(){
347
+ if(_loading)return;_loading=true;
348
+ const el=document.getElementById('vtv-load');if(el)el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
349
+ try{
350
+ const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)});
351
+ if(r.ok){
352
+ const d=await r.json();
353
+ CHANNELS.forEach(ch=>{
354
+ const i=d[ch.id];
355
+ if(i&&i.stream_url){
356
+ // Store BOTH direct and proxied URLs - try direct first
357
+ STREAMS[ch.id]=[i.stream_url, proxyUrl(i.stream_url)];
358
+ } else STREAMS[ch.id]=[];
359
+ });
360
+ }
361
+ }catch(e){console.warn('loadAllStreams error:',e);}
362
+ CHANNELS.forEach(ch=>{
363
+ const t=document.getElementById('vtvt-'+ch.id);
364
+ if(t){
365
+ if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off');t.textContent=ch.name;}
366
+ else{t.style.opacity='0.35';t.textContent=ch.name+' ✕';}
367
+ }
368
+ });
369
+ _loading=false;_streamsLoaded=true;
370
  }
371
 
372
  function buildBlock(){
373
+ const w=document.createElement('div');w.className='vtv-wrap';w.id='vtv-block';
374
+ let tabs='';CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
375
  w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
376
  '<div class="vtv-tabs">'+tabs+'</div>'+
377
  '<div class="vtv-frame" id="vtv-frame">'+
 
385
  }
386
 
387
  async function loadEpg(chId){
388
+ const el=document.getElementById('vtv-epg-list');if(!el)return;
389
  el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
390
+ try{const r=await fetch('/api/vtv/epg/'+chId,{signal:AbortSignal.timeout(10000)});if(!r.ok)throw new Error();const d=await r.json();const p=d.programs||[];if(!p.length){el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch</div>';return;}let h='';p.forEach(x=>{const n=x.now?' now':'';h+='<div class="vtv-epg-item'+n+'"><span class="t">'+(x.time||'')+(x.end_time?'-'+x.end_time:'')+'</span> <span class="n">'+(x.title||'')+'</span></div>';});el.innerHTML=h;const ni=el.querySelector('.vtv-epg-item.now');if(ni)ni.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});}catch(e){el.innerHTML='<div class="vtv-epg-empty">Không tải được lịch</div>';}
391
  }
392
 
393
+ 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';}};
394
+ 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){}};
395
 
396
  function pinBlock(){
397
+ const h=document.getElementById('view-home');if(!h||document.getElementById('vtv-block'))return;
398
+ h.insertBefore(buildBlock(),h.firstChild);_blockInserted=true;
399
  setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
400
  if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
401
  }
402
 
403
+ window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);else loadAllStreams().then(()=>{if(DEFAULT_CHANNEL)window._vtvPlay(DEFAULT_CHANNEL);});};
 
 
 
404
 
405
+ function ensureVideoReady(video){
406
+ video.setAttribute('playsinline','');video.setAttribute('webkit-playsinline','');
407
+ video.setAttribute('controlslist','nodownload');video.style.backgroundColor='#000';
 
 
 
 
 
 
408
  }
409
 
410
  window._vtvPlay=function(chId){
411
+ const ch=CHANNELS.find(c=>c.id===chId);if(!ch)return;_currentCh=chId;
412
  document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
413
+ const tab=document.getElementById('vtvt-'+chId);if(tab)tab.classList.add('on');
414
+ 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');
415
  ensureVideoReady(video);
416
+ video.style.display='block';video.style.visibility='hidden';video.style.opacity='0';
417
+ errEl.style.display='none';loadEl.style.display='flex';
 
 
 
418
  loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
419
  if(_hls){_hls.destroy();_hls=null;}
420
  const urls=STREAMS[chId]||[];
 
427
  function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
428
  if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
429
  const src=urls[idx];
430
+ const isDirect=!src.startsWith('/api/proxy/');
431
+ loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+(isDirect?' (trực tiếp)':' (proxy)')+' ('+(idx+1)+'/'+urls.length+')...';
432
 
433
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
 
434
  const hls=new Hls({
435
  enableWorker:true,
436
  lowLatencyMode:false,
437
+ startLevel:0,
438
  capLevelToPlayerSize:true,
439
+ maxBufferLength:30,
440
  maxMaxBufferLength:120,
441
  backBufferLength:90,
442
+ // For direct URLs, set withCredentials to include cookies if needed
443
+ xhrSetup:function(xhr,url){
444
+ xhr.open('GET',url,true);
445
+ // Set referer header for FPTPlay
446
+ if(url.includes('fptplay')){
447
+ xhr.setRequestHeader('Referer','https://fptplay.vn/');
448
+ }
449
+ },
450
  });
451
  _hls=hls;
452
  hls.loadSource(src);
 
454
 
455
  hls.on(Hls.Events.MANIFEST_PARSED,()=>{
456
  loadEl.style.display='none';
 
457
  video.style.visibility='visible';
458
  video.style.opacity='1';
459
+ video.play().then(()=>{console.log('[VTV] Playing:',name);}).catch(e=>{
460
+ console.warn('[VTV] Play failed:',e.message);
461
+ video.muted=true;video.play().catch(()=>{});
 
 
 
 
 
462
  });
463
  });
464
 
465
  hls.on(Hls.Events.FRAG_LOADED,()=>{
466
+ if(video.style.visibility==='hidden'){
467
+ video.style.visibility='visible';video.style.opacity='1';loadEl.style.display='none';
 
 
468
  }
469
  });
470
 
471
  let retryCount=0;
 
472
  hls.on(Hls.Events.ERROR,(ev,data)=>{
473
  if(data.fatal){
 
474
  if(data.type===Hls.ErrorTypes.NETWORK_ERROR){
475
  retryCount++;
476
+ if(retryCount<=3){
477
+ loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối lại '+name+' ('+retryCount+'/3)...';
478
+ setTimeout(()=>{hls.startLoad();},1500*retryCount);
 
 
 
479
  }else{
480
+ // Try next URL (proxy if direct failed, or vice versa)
481
+ hls.destroy();_hls=null;
482
  _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
483
  }
484
  }else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){
485
  try{hls.recoverMediaError();}catch(e){}
486
  }else{
487
+ hls.destroy();_hls=null;
 
488
  _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);
489
  }
490
  }else{
491
+ console.warn('HLS non-fatal:',data.type,data.details);
492
  }
493
  });
 
494
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
495
  video.src=src;
496
  video.addEventListener('loadedmetadata',()=>{
497
+ loadEl.style.display='none';video.style.visibility='visible';video.style.opacity='1';video.play().catch(()=>{});
 
 
 
498
  },{once:true});
499
  video.addEventListener('playing',()=>{
500
+ loadEl.style.display='none';video.style.visibility='visible';video.style.opacity='1';
 
 
 
 
 
501
  },{once:true});
502
+ video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
503
  }else{
504
+ loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';
 
 
505
  }
506
  }
507
 
508
  // ===== NAV HOOKS =====
509
  const _origShowView=window.showView;
510
+ window.showView=function(id){
511
+ if(id==='view-home'&&_miniActive)closeMiniPlayer();
512
+ else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]){
513
+ const block=document.getElementById('vtv-block');
514
+ if(block&&!document.getElementById('view-home').contains(block)){
515
+ activateMiniPlayer();
516
+ }
517
+ }
518
+ if(_origShowView)_origShowView(id);
519
+ };
520
+
521
+ const _origSwitchCat=window.switchCat;
522
+ window.switchCat=function(cat){
523
+ if(cat==='home'||_cat==='home'){
524
+ if(!_blockInserted&&!document.getElementById('vtv-block'))pinBlock();
525
+ else if(!document.getElementById('vtv-block'))pinBlock();
526
+ }
527
+ if(_origSwitchCat)_origSwitchCat(cat);
528
+ };
529
+
530
+ // Auto-insert on home
531
+ if(document.getElementById('view-home')&&!document.getElementById('vtv-block')){
532
+ pinBlock();
533
  }
534
+
535
+ // Expose for manual init
536
+ window._vtvPinBlock=pinBlock;
537
+ window._vtvLoadStreams=loadAllStreams;
538
+ window._vtvPlayChannel=window._vtvPlay;
539
+
540
  })();