bep40 commited on
Commit
dde5da5
·
verified ·
1 Parent(s): 80d53b8

Upload static/yt_live.js

Browse files
Files changed (1) hide show
  1. static/yt_live.js +349 -406
static/yt_live.js CHANGED
@@ -1,37 +1,33 @@
1
- // === VNEWS — VTV1-VTV10 + VTVPrime LIVE CHANNELS ===
2
- // Default channel: VTV6
3
- // Features: PiP button, floating mini-player, INLINE RECORDER
4
- // Fixed: VTV stays at top ONLY when user clicks pin button
5
 
6
  (function(){
7
  if(window._ytLiveLoaded) return;
8
  window._ytLiveLoaded = true;
9
 
10
  const CHANNELS = [
11
- {id:'vtv1', name:'VTV1', badge:'Tin tức'},
12
- {id:'vtv2', name:'VTV2', badge:'Khoa học'},
13
- {id:'vtv3', name:'VTV3', badge:'Giải trí'},
14
- {id:'vtv4', name:'VTV4', badge:'Quốc tế'},
15
- {id:'vtv5', name:'VTV5', badge:'Miền Nam'},
16
- {id:'vtv6', name:'VTV6', badge:'Thanh niên'},
17
- {id:'vtv7', name:'VTV7', badge:'Giáo dục'},
18
- {id:'vtv8', name:'VTV8', badge:'Miền Trung'},
19
- {id:'vtv9', name:'VTV9', badge:'Miền Bắc'},
20
- {id:'vtv10', name:'VTV10', badge:'VTV Cần Thơ'},
21
- {id:'vtvprime', name:'VTVPrime', badge:'Prime'},
22
  ];
23
  const DEFAULT_CHANNEL = 'vtv6';
24
  const NEEDS_PROXY = /fptplay\.net/;
25
-
26
  const STREAMS = {};
27
- let _currentCh = null;
28
- let _hls = null;
29
- let _loading = false;
30
- let _blockInserted = false;
31
- let _streamsLoaded = false;
32
- let _pipActive = false;
33
- let _miniActive = false;
34
- let _vtvPinned = false;
 
 
 
35
 
36
  // ===== STYLES =====
37
  const style = document.createElement('style');
@@ -75,12 +71,9 @@
75
  .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
76
  .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
77
  .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
78
-
79
- /* ===== PIN / STICKY MODE ===== */
80
  .vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
81
  .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
82
  .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
83
-
84
  .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)}
85
  .vtv-wrap.vtv-sticky .vtv-epg{display:none}
86
  .vtv-wrap.vtv-sticky .vtv-controls{display:none}
@@ -104,9 +97,9 @@
104
 
105
  .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
106
  .vtv-inline-rec.show{display:block}
107
- .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px}
108
- .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%}
109
- .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:3px;z-index:2}
110
  .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
111
  .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
112
  .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
@@ -124,7 +117,9 @@
124
 
125
  .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
126
  .vtv-rec-panel.show{display:block}
127
- .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px;display:flex;align-items:center;gap:6px}
 
 
128
  .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
129
  .vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
130
  .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
@@ -132,9 +127,7 @@
132
  .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
133
  .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
134
  .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
135
- .vtv-rec-panel .rec-preview-wrap{margin-top:8px;text-align:center}
136
- .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:200px;border-radius:6px;background:#000}
137
- .vtv-rec-panel .rec-processing{text-align:center;padding:12px;color:#9b59b6;font-size:12px}
138
 
139
  /* ===== MINI PLAYER ===== */
140
  .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
@@ -155,36 +148,29 @@
155
  `;
156
  document.head.appendChild(style);
157
 
158
- // ===== INLINE RECORDER STATE =====
159
- let _recState = {
160
- active: false,
161
- startTime: null,
162
- endTime: null,
163
- isRecording: false,
164
- recorder: null,
165
- chunks: [],
166
- blob: null,
167
- ratio: 'original',
168
- _dragMarker: null,
169
- _timer: null,
170
- _recTimer: null,
171
- };
172
 
173
- // ===== INLINE RECORDER UI =====
 
 
 
 
 
174
  function buildInlineRecorder() {
175
  const controls = document.querySelector('.vtv-controls');
176
  if (!controls || document.getElementById('vtv-rec-btn')) return;
177
 
178
- // Record button in controls bar
179
  const recBtn = document.createElement('button');
180
  recBtn.id = 'vtv-rec-btn';
181
  recBtn.className = 'vtv-rec-btn';
182
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
183
  recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
184
- recBtn.onclick = toggleInlineRecorder;
185
  controls.appendChild(recBtn);
186
 
187
- // Inline recorder panel
188
  const inlineRec = document.createElement('div');
189
  inlineRec.id = 'vtv-inline-rec';
190
  inlineRec.className = 'vtv-inline-rec';
@@ -210,7 +196,7 @@
210
  `;
211
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
212
 
213
- // Post-record panel (ratio + download/share)
214
  const recPanel = document.createElement('div');
215
  recPanel.id = 'vtv-rec-panel';
216
  recPanel.className = 'vtv-rec-panel';
@@ -228,7 +214,7 @@
228
  <button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button>
229
  <button class="rec-share" onclick="window._vtvRecShare()">🎬 Tạo Short AI</button>
230
  </div>
231
- <div class="rec-processing" id="vtv-rec-proc" style="display:none">
232
  <div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div>
233
  <span id="vtv-rec-proc-text">Đang xử lý...</span>
234
  </div>
@@ -237,146 +223,121 @@
237
 
238
  // Timeline click
239
  document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
240
- const video = document.getElementById('vtv-player');
241
- if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
242
- const rect = this.getBoundingClientRect();
243
- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
244
- const time = pct * 60; // max 60s
245
-
246
- if (_recState.startTime === null) {
247
- _recState.startTime = time;
248
- const mk = document.getElementById('vtv-rec-mk-start');
249
- mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
250
- document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
251
- document.getElementById('vtv-rec-set-end').disabled = false;
252
- recStatus('Điểm đầu: ' + fmtSec(time) + '. Chọn điểm cuối.', '');
253
- } else if (_recState.endTime === null) {
254
- if (time <= _recState.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
255
- const dur = time - _recState.startTime;
256
- if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
257
- _recState.endTime = time;
258
- const mk = document.getElementById('vtv-rec-mk-end');
259
- mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
260
- document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
261
- updateRecProgress();
262
- recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
263
- document.getElementById('vtv-rec-go').disabled = false;
264
- }
265
  });
266
 
267
  // Drag markers
268
- setupRecDrag('vtv-rec-mk-start', 'start');
269
- setupRecDrag('vtv-rec-mk-end', 'end');
270
- }
271
-
272
- function setupRecDrag(mkId, which) {
273
- const mk = document.getElementById(mkId);
274
- if (!mk) return;
275
- mk.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); _recState._dragMarker = which; });
276
- mk.addEventListener('touchstart', function(e) { e.preventDefault(); e.stopPropagation(); _recState._dragMarker = which; }, {passive:false});
277
  }
278
 
279
- // Global drag handlers
280
  document.addEventListener('mousemove', function(e) {
281
- if (!_recState._dragMarker) return;
 
 
 
 
 
 
 
 
 
 
282
  const bar = document.getElementById('vtv-rec-bar');
283
  if (!bar) return;
284
  const rect = bar.getBoundingClientRect();
285
- const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
286
  const time = pct * 60;
287
- const mkId = _recState._dragMarker === 'start' ? 'vtv-rec-mk-start' : 'vtv-rec-mk-end';
288
- const mk = document.getElementById(mkId);
289
- if (!mk) return;
290
- if (_recState._dragMarker === 'start') {
291
- if (_recState.endTime !== null && time >= _recState.endTime - 1) return;
292
- _recState.startTime = time;
293
- mk.style.left = (pct * 100) + '%';
294
  document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
295
  } else {
296
- if (_recState.startTime !== null && time <= _recState.startTime + 1) return;
297
- _recState.endTime = time;
298
- mk.style.left = (pct * 100) + '%';
 
299
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
300
  }
301
- updateRecProgress();
302
- });
303
- document.addEventListener('touchmove', function(e) {
304
- if (!_recState._dragMarker) return;
 
 
305
  const bar = document.getElementById('vtv-rec-bar');
306
- if (!bar) return;
307
  const rect = bar.getBoundingClientRect();
308
- const pct = Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width));
309
  const time = pct * 60;
310
- const mkId = _recState._dragMarker === 'start' ? 'vtv-rec-mk-start' : 'vtv-rec-mk-end';
311
- const mk = document.getElementById(mkId);
312
- if (!mk) return;
313
- if (_recState._dragMarker === 'start') {
314
- if (_recState.endTime !== null && time >= _recState.endTime - 1) return;
315
- _recState.startTime = time; mk.style.left = (pct * 100) + '%';
316
  document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
317
- } else {
318
- if (_recState.startTime !== null && time <= _recState.startTime + 1) return;
319
- _recState.endTime = time; mk.style.left = (pct * 100) + '%';
 
 
 
 
 
 
320
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
 
 
 
321
  }
322
- updateRecProgress();
323
- }, {passive:false});
324
- document.addEventListener('mouseup', function() { _recState._dragMarker = null; });
325
- document.addEventListener('touchend', function() { _recState._dragMarker = null; });
326
 
327
- function updateRecProgress() {
328
- const s = _recState.startTime || 0;
329
- const e = _recState.endTime || 60;
330
  const dur = e - s;
331
  document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
332
  document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
333
  document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
334
  }
335
 
336
- function recStatus(msg, type) {
337
- const el = document.getElementById('vtv-rec-status');
338
- if (!el) return;
339
- el.textContent = msg;
340
- el.className = 'rec-status' + (type ? ' ' + type : '');
341
- }
342
-
343
- // ===== INLINE RECORDER CONTROLS =====
344
  window._vtvRecSetStart = function() {
345
  const video = document.getElementById('vtv-player');
346
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
347
- const cur = video.currentTime || 0;
348
- _recState.startTime = cur;
349
- const pct = cur / 60 * 100;
350
  const mk = document.getElementById('vtv-rec-mk-start');
351
  mk.style.display = 'block'; mk.style.left = pct + '%';
352
- document.getElementById('vtv-rec-time-start').textContent = fmtSec(cur);
353
  document.getElementById('vtv-rec-set-end').disabled = false;
354
- recStatus('Điểm đầu: ' + fmtSec(cur), '');
355
  };
356
 
357
  window._vtvRecSetEnd = function() {
358
- if (_recState.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
359
  const video = document.getElementById('vtv-player');
360
  if (!video) return;
361
  const cur = video.currentTime || 0;
362
- if (cur <= _recState.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
363
- const dur = cur - _recState.startTime;
364
  if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
365
- _recState.endTime = cur;
366
  const pct = cur / 60 * 100;
367
  const mk = document.getElementById('vtv-rec-mk-end');
368
  mk.style.display = 'block'; mk.style.left = pct + '%';
369
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
370
- updateRecProgress();
371
  recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
372
  document.getElementById('vtv-rec-go').disabled = false;
373
  };
374
 
375
  window._vtvRecReset = function() {
376
- _recState.startTime = null; _recState.endTime = null;
377
- _recState.isRecording = false; _recState.blob = null;
378
- if (_recState.recorder && _recState.recorder.state !== 'inactive') _recState.recorder.stop();
379
- clearTimeout(_recState._recTimer);
380
  document.getElementById('vtv-rec-mk-start').style.display = 'none';
381
  document.getElementById('vtv-rec-mk-end').style.display = 'none';
382
  document.getElementById('vtv-rec-progress').style.width = '0%';
@@ -388,18 +349,22 @@
388
  document.getElementById('vtv-rec-go').disabled = true;
389
  document.getElementById('vtv-rec-panel').classList.remove('show');
390
  recStatus('Đã reset.', '');
391
- // Reset rec button
392
  const btn = document.getElementById('vtv-rec-btn');
393
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
394
  };
395
 
 
 
 
 
 
 
396
  window._vtvRecGo = function() {
397
- if (_recState.startTime === null || _recState.endTime === null) return;
398
- if (_recState.isRecording) {
399
- // Stop
400
- if (_recState.recorder && _recState.recorder.state !== 'inactive') _recState.recorder.stop();
401
- _recState.isRecording = false;
402
- clearTimeout(_recState._recTimer);
403
  const btn = document.getElementById('vtv-rec-btn');
404
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
405
  return;
@@ -408,73 +373,92 @@
408
  const video = document.getElementById('vtv-player');
409
  if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
410
 
411
- _recState.chunks = [];
412
- _recState.isRecording = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
  let mimeType = 'video/webm;codecs=vp9,opus';
415
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
416
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
417
 
418
- try {
419
- const stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
420
- if (!stream) { recStatus('❌ Không hỗ trợ captureStream!', 'err'); _recState.isRecording = false; return; }
421
- _recState.recorder = new MediaRecorder(stream, {mimeType});
422
- } catch(e) { recStatus('❌ Lỗi: ' + e.message, 'err'); _recState.isRecording = false; return; }
423
-
424
- _recState.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _recState.chunks.push(e.data); };
425
- _recState.recorder.onstop = function() {
426
- _recState.isRecording = false;
427
- _recState.blob = new Blob(_recState.chunks, {type: 'video/webm'});
 
 
 
428
  const btn = document.getElementById('vtv-rec-btn');
429
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
430
- // Show panel
431
  document.getElementById('vtv-rec-panel').classList.add('show');
432
  const pv = document.getElementById('vtv-rec-preview-vid');
433
- pv.src = URL.createObjectURL(_recState.blob);
434
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
435
- recStatus('✅ Record xong! ' + (_recState.blob.size/1024/1024).toFixed(1) + 'MB', 'ok');
 
 
 
 
 
 
436
  };
437
- _recState.recorder.onerror = e => { recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err'); _recState.isRecording = false; };
438
 
439
- _recState.recorder.start(1000);
440
- video.currentTime = _recState.startTime;
441
 
442
  const btn = document.getElementById('vtv-rec-btn');
443
  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'; }
444
-
445
  recStatus('🔴 ĐANG RECORD...', 'recording');
446
 
447
- const dur = (_recState.endTime - _recState.startTime) * 1000;
448
- _recState._recTimer = setTimeout(() => {
449
- if (_recState.isRecording) {
450
- if (_recState.recorder && _recState.recorder.state !== 'inactive') _recState.recorder.stop();
451
- _recState.isRecording = false;
452
- }
453
  }, dur + 500);
454
  };
455
 
456
- window._vtvRecSetRatio = function(ratio) {
457
- _recState.ratio = ratio;
458
- document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => {
459
- b.classList.toggle('active', b.dataset.ratio === ratio);
460
- });
461
- };
462
-
463
  window._vtvRecDownload = function() {
464
- if (!_recState.blob) return;
465
- if (_recState.ratio === 'original') {
466
- downloadBlob(_recState.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
467
  } else {
468
- applyRatioProcess(_recState.blob, _recState.ratio, 'download');
469
  }
470
  };
471
 
 
472
  window._vtvRecShare = function() {
473
- if (!_recState.blob) return;
474
- if (_recState.ratio !== 'original') {
475
- applyRatioProcess(_recState.blob, _recState.ratio, 'upload');
476
  } else {
477
- uploadToServer(_recState.blob);
478
  }
479
  };
480
 
@@ -485,16 +469,25 @@
485
  recStatus('✅ Đã tải: ' + filename, 'ok');
486
  }
487
 
488
- function uploadToServer(blob) {
 
 
 
 
 
 
 
 
 
489
  const formData = new FormData();
490
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
491
  formData.append('title', `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
492
  formData.append('source', 'vtv_inline_recorder');
493
- showRecProc('⏳ Đang upload...');
494
  fetch('/api/wall', { method: 'POST', body: formData })
495
  .then(r => r.json())
496
  .then(data => {
497
- hideRecProc();
498
  if (data.ok) {
499
  recStatus('✅ Đã tạo Short AI! Xem trong Tường AI.', 'ok');
500
  } else {
@@ -503,22 +496,40 @@
503
  }
504
  })
505
  .catch(e => {
506
- hideRecProc();
507
  recStatus('⚠️ Mất kết nối. Đang tải về...', 'err');
508
  setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
509
  });
510
  }
511
 
512
- function showRecProc(text) {
513
- document.getElementById('vtv-rec-proc').style.display = 'block';
514
- document.getElementById('vtv-rec-proc-text').textContent = text;
515
- }
516
- function hideRecProc() {
517
- document.getElementById('vtv-rec-proc').style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518
  }
519
 
520
- function applyRatioProcess(blob, ratio, action) {
521
- showRecProc(`🔄 Đang xử lý ${ratio}...`);
522
  const video = document.createElement('video');
523
  video.src = URL.createObjectURL(blob);
524
  video.muted = true;
@@ -529,74 +540,109 @@
529
  let cw, ch, sx, sy;
530
  if (ratio === '1:1') {
531
  const size = Math.min(vw, vh); cw = ch = size;
532
- sx = (vw - size) / 2; sy = (vh - size) / 2;
533
  } else if (ratio === '9:16') {
534
  ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
535
  sx = Math.floor((vw - cw) / 2); sy = 0;
536
  } else { cw = vw; ch = vh; sx = sy = 0; }
537
 
 
 
538
  const canvas = document.createElement('canvas');
539
  canvas.width = cw; canvas.height = ch;
540
  const ctx = canvas.getContext('2d');
 
 
541
  const canvasStream = canvas.captureStream(30);
542
 
543
- let combinedStream = canvasStream;
544
- try {
545
- const vs = video.captureStream ? video.captureStream() : null;
546
- if (vs) {
547
- const at = vs.getAudioTracks();
548
- if (at.length > 0) combinedStream = new MediaStream([...canvasStream.getVideoTracks(), ...at]);
 
 
 
 
 
 
 
 
 
 
 
 
549
  }
550
- } catch(e) {}
 
 
 
 
 
 
551
 
552
- const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus') ? 'video/webm;codecs=vp9,opus' : 'video/webm';
553
  let rec;
554
- try { rec = new MediaRecorder(combinedStream, {mimeType: mime}); }
555
- catch(e) { hideRecProc(); recStatus('❌ Lỗi: ' + e.message, 'err'); return; }
556
 
557
  const chunks = [];
558
- rec.ondataavailable = e => { if(e.data.size > 0) chunks.push(e.data); };
559
  rec.onstop = () => {
560
- hideRecProc();
561
- const out = new Blob(chunks, {type: 'video/webm'});
562
  URL.revokeObjectURL(video.src);
 
 
 
 
 
563
  if (action === 'download') {
564
  downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
565
  } else {
566
- uploadToServer(out);
567
  }
568
  };
 
 
569
  rec.start(1000);
570
  video.currentTime = 0;
 
 
 
 
 
 
571
  video.play().then(() => {
572
  function draw() {
573
- if (video.paused || video.ended) { rec.stop(); return; }
 
 
 
574
  ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
575
  requestAnimationFrame(draw);
576
  }
577
  draw();
578
  setTimeout(() => { if(rec.state !== 'inactive') rec.stop(); }, video.duration * 1000 + 500);
579
- }).catch(e => { hideRecProc(); recStatus('❌ ' + e.message, 'err'); });
580
  };
581
- video.onerror = function() { hideRecProc(); recStatus('❌ Không đọc được video.', 'err'); };
582
- }
583
 
584
- window._vtvRecToggle = toggleInlineRecorder;
 
585
 
586
- function toggleInlineRecorder() {
 
587
  const panel = document.getElementById('vtv-inline-rec');
588
- if (!panel) { buildInlineRecorder(); setTimeout(toggleInlineRecorder, 200); return; }
589
- _recState.active = !_recState.active;
590
- panel.classList.toggle('show', _recState.active);
591
  const btn = document.getElementById('vtv-rec-btn');
592
  if (btn) {
593
- if (_recState.active) {
594
  btn.style.background = '#3a1a1a';
595
- btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Đóng';
596
  } else {
597
  btn.style.background = '';
598
  btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
599
- // Also hide post-record panel
600
  document.getElementById('vtv-rec-panel').classList.remove('show');
601
  }
602
  }
@@ -607,16 +653,13 @@
607
  const block = document.getElementById('vtv-block');
608
  if(!block) return;
609
  const header = block.querySelector('.vtv-head');
610
- if(!header) return;
611
- if(document.getElementById('vtv-pin-btn')) return;
612
  const btn = document.createElement('button');
613
- btn.id = 'vtv-pin-btn';
614
- btn.className = 'vtv-pin-btn';
615
  btn.innerHTML = '📌 Ghim';
616
  btn.title = 'Ghim VTV cố định trên đầu trang';
617
  btn.onclick = function(e){
618
- e.stopPropagation();
619
- _vtvPinned = !_vtvPinned;
620
  if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
621
  else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
622
  };
@@ -628,208 +671,108 @@
628
  function createMiniPlayer(){
629
  if(document.getElementById('vtv-mini')) return;
630
  const mini = document.createElement('div');
631
- mini.className = 'vtv-mini';
632
- mini.id = 'vtv-mini';
633
- mini.innerHTML = `
634
- <div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
635
- <div class="vtv-mini-bar">
636
- <div style="display:flex;align-items:center;flex:1;min-width:0">
637
- <span class="vtv-mini-ch" id="vtv-mini-ch">VTV</span>
638
- <span class="vtv-mini-epg" id="vtv-mini-epg"></span>
639
- </div>
640
- <div class="vtv-mini-btns">
641
- <button class="vtv-mini-btn" id="vtv-mini-pip" title="PiP">📺</button>
642
- <button class="vtv-mini-btn" id="vtv-mini-expand" title="Mở rộng">⬆️</button>
643
- <button class="vtv-mini-btn" id="vtv-mini-hide" title="Thu nhỏ">⬇️</button>
644
- <button class="vtv-mini-btn x" id="vtv-mini-close" title="Đóng">✕</button>
645
- </div>
646
- </div>
647
  <div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
648
  document.body.appendChild(mini);
649
- document.getElementById('vtv-mini-pip').onclick = toggleMiniPiP;
650
- document.getElementById('vtv-mini-expand').onclick = expandFromMini;
 
 
 
651
  document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
652
  document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
653
  document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
654
  }
655
-
656
- function closeMiniPlayer(){
657
- const mini = document.getElementById('vtv-mini');
658
- if(mini){ const v = document.getElementById('vtv-mini-vid'); if(v){ v.pause(); v.src = ''; } mini.remove(); }
659
- _miniActive = false;
660
- }
661
-
662
- function expandFromMini(){ closeMiniPlayer(); if(typeof switchCat === 'function') switchCat('home'); }
663
-
664
  function activateMiniPlayer(){
665
- if(!_currentCh) return;
666
- createMiniPlayer(); _miniActive = true;
667
- const mini = document.getElementById('vtv-mini');
668
- const miniVid = document.getElementById('vtv-mini-vid');
669
- const mainVid = document.getElementById('vtv-player');
670
- if(_hls){
671
- const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:10});
672
- hls.loadSource(STREAMS[_currentCh][0]); hls.attachMedia(miniVid); miniVid.play().catch(()=>{});
673
- } else if(mainVid && mainVid.src){ miniVid.src = mainVid.src; miniVid.play().catch(()=>{}); }
674
- const ch = CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent = ch.name;
675
- updateMiniEpg(); mini.classList.add('show'); mini.classList.remove('hidden');
676
- }
677
-
678
- function updateMiniEpg(){
679
- if(!_currentCh) return;
680
- fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{
681
- const progs = d.programs||[]; const now = progs.find(p=>p.now);
682
- document.getElementById('vtv-mini-epg').textContent = now ? now.time+' '+now.title : (progs[0] ? progs[0].time+' '+progs[0].title : '');
683
- }).catch(()=>{});
684
- }
685
-
686
- async function toggleMiniPiP(){
687
- const v = document.getElementById('vtv-mini-vid'); if(!v) return;
688
- try{ if(document.pictureInPictureElement === v){ await document.exitPictureInPicture(); } else { await v.requestPictureInPicture(); } } catch(e){ alert('Không hỗ trợ PiP'); }
689
  }
690
 
691
  // ===== MAIN PLAYER =====
692
  async function loadAllStreams(){
693
- if(_loading) return; _loading = true;
694
- const el = document.getElementById('vtv-load');
695
- if(el) el.innerHTML = '<div class="vtv-spinner"></div>Đang tải kênh...';
696
- try{
697
- const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
698
- if(r.ok){ const data = await r.json(); CHANNELS.forEach(ch=>{ const info = data[ch.id]; if(info && info.stream_url){ let url = info.stream_url; if(NEEDS_PROXY.test(url)) url = '/api/proxy/m3u8/vtv?url='+encodeURIComponent(url); STREAMS[ch.id] = [url]; } else { STREAMS[ch.id] = []; } }); }
699
- } catch(e){ console.warn('VTV API error:', e); }
700
- CHANNELS.forEach(ch=>{ const tab = document.getElementById('vtvt-'+ch.id); if(tab){ if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){ tab.classList.remove('off'); tab.textContent=ch.name; } else { tab.style.opacity='0.35'; tab.textContent=ch.name+' ✕'; } } });
701
- _loading = false; _streamsLoaded = true;
702
  }
703
 
704
  function buildBlock(){
705
- const w = document.createElement('div');
706
- w.className = 'vtv-wrap'; w.id = 'vtv-block';
707
- let tabs = '';
708
- CHANNELS.forEach(ch=>{ tabs += '<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>'; });
709
- w.innerHTML =
710
- '<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>' +
711
- '<div class="vtv-tabs">'+tabs+'</div>' +
712
- '<div class="vtv-frame">' +
713
- '<div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div>' +
714
- '<video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video>' +
715
- '<div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div>' +
716
- '</div>' +
717
- '<div class="vtv-controls">' +
718
- '<button class="vtv-pip-btn" id="vtv-pip-btn" onclick="window._vtvTogglePiP()">' +
719
- '<svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="12" y="10" width="8" height="6" rx="1" fill="currentColor"/></svg>PiP' +
720
- '</button>' +
721
- '<span style="flex:1"></span>' +
722
- '<span style="font-size:9px;color:#666" id="vtv-status"></span>' +
723
- '</div>' +
724
- '<div class="vtv-epg" id="vtv-epg">' +
725
- '<div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" id="vtv-epg-toggle" onclick="window._vtvToggleEpg()">Ẩn</button></div>' +
726
- '<div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div>' +
727
- '</div>';
728
  return w;
729
  }
730
 
731
  async function loadEpg(chId){
732
- const el = document.getElementById('vtv-epg-list'); if(!el) return;
733
- el.innerHTML = '<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
734
- try{
735
- const r = await fetch('/api/vtv/epg/'+chId, {signal: AbortSignal.timeout(10000)});
736
- if(!r.ok) throw new Error('fail');
737
- const data = await r.json(); const progs = data.programs||[];
738
- if(!progs.length){ el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch phát sóng</div>'; return; }
739
- let h = '';
740
- progs.forEach(p=>{ const nw = p.now ? ' now' : ''; h += '<div class="vtv-epg-item'+nw+'"><span class="t">'+(p.time||'')+(p.end_time?'-'+p.end_time:'')+'</span> <span class="n">'+(p.title||'')+'</span></div>'; });
741
- el.innerHTML = h;
742
- const nowItem = el.querySelector('.vtv-epg-item.now');
743
- if(nowItem) nowItem.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});
744
- } catch(e){ el.innerHTML = '<div class="vtv-epg-empty">Không tải được lịch</div>'; }
745
  }
746
 
747
- window._vtvToggleEpg = function(){
748
- const l = document.getElementById('vtv-epg-list'), b = document.getElementById('vtv-epg-toggle');
749
- if(!l||!b) return;
750
- if(l.style.display==='none'){ l.style.display='flex'; b.textContent='Ẩn'; }
751
- else { l.style.display='none'; b.textContent='Hiện'; }
752
- };
753
-
754
- window._vtvTogglePiP = async function(){
755
- const v = document.getElementById('vtv-player'); const btn = document.getElementById('vtv-pip-btn'); if(!v) return;
756
- try{
757
- if(document.pictureInPictureElement === v){ await document.exitPictureInPicture(); _pipActive = false; if(btn) btn.classList.remove('on'); }
758
- else { await v.requestPictureInPicture(); _pipActive = true; if(btn) btn.classList.add('on'); }
759
- } catch(e){ alert('Không hỗ trợ PiP'); }
760
- };
761
 
762
  function pinBlock(){
763
- const h = document.getElementById('view-home');
764
- if(!h||document.getElementById('vtv-block')) return;
765
- h.insertBefore(buildBlock(), h.firstChild);
766
- _blockInserted = true;
767
- setTimeout(()=>{ setupPinButton(); buildInlineRecorder(); }, 500);
768
- if(!_streamsLoaded){ loadAllStreams().then(()=>{ setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL), 300); }); }
769
  }
770
 
771
- window._vtvRetry = function(){ if(_currentCh) window._vtvPlay(_currentCh); };
772
 
773
- window._vtvPlay = function(chId){
774
- const ch = CHANNELS.find(c=>c.id===chId); if(!ch) return;
775
- _currentCh = chId;
776
  document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
777
- const tab = document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
778
- const video = document.getElementById('vtv-player');
779
- const errEl = document.getElementById('vtv-err'); const loadEl = document.getElementById('vtv-load');
780
- const errMsg = document.getElementById('vtv-err-msg');
781
  video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
782
- loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
783
- if(_hls){_hls.destroy(); _hls=null;}
784
- const urls = STREAMS[chId]||[];
785
- if(!urls.length){ loadEl.style.display='none'; errEl.style.display='flex'; errMsg.textContent = chId==='vtvprime' ? 'VTVPrime: Kênh trả phí.' : ch.name+': Không có luồng.'; return; }
786
- _tryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
787
  loadEpg(chId);
788
- if(_miniActive){ document.getElementById('vtv-mini-ch').textContent = ch.name; updateMiniEpg(); }
789
  };
790
 
791
- function _tryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
792
- if(idx>=urls.length){ loadEl.style.display='none'; errEl.style.display='flex'; errMsg.textContent = name+': Tất cả nguồn lỗi.'; return; }
793
- const src = urls[idx];
794
- loadEl.innerHTML = '<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
795
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
796
- const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:20});
797
- _hls = hls; hls.loadSource(src); hls.attachMedia(video);
798
- hls.on(Hls.Events.MANIFEST_PARSED,()=>{ video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; });
799
- let rec = 0;
800
- 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); } } });
801
- } else if(video.canPlayType('application/vnd.apple.mpegurl')){
802
- video.src = src;
803
- video.addEventListener('loadedmetadata',()=>{ video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; },{once:true});
804
  video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
805
- } else { loadEl.style.display='none'; errEl.style.display='flex'; errMsg.textContent='Không hỗ trợ HLS'; }
806
  }
807
 
808
- // ===== NAVIGATION HOOKS =====
809
- const _origShowView = window.showView;
810
- window.showView = function(id){
811
- if(id==='view-home' && _miniActive){ closeMiniPlayer(); }
812
- else if(id!=='view-home' && _currentCh && STREAMS[_currentCh] && STREAMS[_currentCh].length>0 && !_miniActive){ activateMiniPlayer(); }
813
- return _origShowView.apply(this, arguments);
814
- };
815
- const _origReadArticle = window.readArticle;
816
- if(_origReadArticle){ window.readArticle = function(url){ if(_currentCh && STREAMS[_currentCh] && STREAMS[_currentCh].length>0 && !_miniActive) activateMiniPlayer(); return _origReadArticle.apply(this, arguments); }; }
817
- const _origSwitchCat = window.switchCat;
818
- 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); }; }
819
-
820
- // Override loadHome to inject VTV block
821
- const _origLoadHome = window.loadHome;
822
- if(_origLoadHome && !_origLoadHome.__vtvWrapped){
823
- window.loadHome = async function(){
824
- const old = document.getElementById('vtv-block');
825
- if(old) old.remove();
826
- _blockInserted = false;
827
- const r = await _origLoadHome.apply(this, arguments);
828
- try{ pinBlock(); }catch(e){}
829
- return r;
830
- };
831
- window.loadHome.__vtvWrapped = true;
832
- }
833
 
834
- function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
 
 
 
 
835
  })();
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v4 ===
2
+ // Features: PiP, mini-player, INLINE RECORDER with ratio crop + upload
3
+ // No separate recorder page everything inline on the VTV player
 
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
7
  window._ytLiveLoaded = true;
8
 
9
  const CHANNELS = [
10
+ {id:'vtv1',name:'VTV1',badge:'Tin tức'},{id:'vtv2',name:'VTV2',badge:'Khoa học'},
11
+ {id:'vtv3',name:'VTV3',badge:'Giải trí'},{id:'vtv4',name:'VTV4',badge:'Quốc tế'},
12
+ {id:'vtv5',name:'VTV5',badge:'Miền Nam'},{id:'vtv6',name:'VTV6',badge:'Thanh niên'},
13
+ {id:'vtv7',name:'VTV7',badge:'Giáo dục'},{id:'vtv8',name:'VTV8',badge:'Miền Trung'},
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 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 = {
25
+ active: false, startTime: null, endTime: null,
26
+ isRecording: false, recorder: null, chunks: [], blob: null,
27
+ ratio: 'original', _dragMarker: null, _recTimer: null,
28
+ // Store the raw stream for recording (with audio)
29
+ rawStream: null,
30
+ };
31
 
32
  // ===== STYLES =====
33
  const style = document.createElement('style');
 
71
  .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
72
  .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
73
  .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
 
 
74
  .vtv-pin-btn{position:absolute;top:6px;right:8px;z-index:5;background:rgba(0,0,0,.5);border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:700;display:flex;align-items:center;gap:3px}
75
  .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
76
  .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
 
77
  .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)}
78
  .vtv-wrap.vtv-sticky .vtv-epg{display:none}
79
  .vtv-wrap.vtv-sticky .vtv-controls{display:none}
 
97
 
98
  .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
99
  .vtv-inline-rec.show{display:block}
100
+ .vtv-inline-rec .rec-bar{position:relative;height:32px;background:#000;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid #2a2a4a;margin-bottom:6px;user-select:none}
101
+ .vtv-inline-rec .rec-bar .rec-progress{position:absolute;top:0;bottom:0;background:rgba(155,89,182,.3);left:0;width:0%;pointer-events:none}
102
+ .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
103
  .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
104
  .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
105
  .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
 
117
 
118
  .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
119
  .vtv-rec-panel.show{display:block}
120
+ .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
121
+ .vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
122
+ .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
123
  .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
124
  .vtv-rec-panel .rec-ratio-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
125
  .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
 
127
  .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
128
  .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
129
  .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
130
+ .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
 
 
131
 
132
  /* ===== MINI PLAYER ===== */
133
  .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
 
148
  `;
149
  document.head.appendChild(style);
150
 
151
+ // ===== HELPERS =====
152
+ function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
+ function recStatus(msg, type) {
155
+ const el = document.getElementById('vtv-rec-status');
156
+ if (el) { el.textContent = msg; el.className = 'rec-status' + (type ? ' ' + type : ''); }
157
+ }
158
+
159
+ // ===== BUILD INLINE RECORDER =====
160
  function buildInlineRecorder() {
161
  const controls = document.querySelector('.vtv-controls');
162
  if (!controls || document.getElementById('vtv-rec-btn')) return;
163
 
164
+ // Record button
165
  const recBtn = document.createElement('button');
166
  recBtn.id = 'vtv-rec-btn';
167
  recBtn.className = 'vtv-rec-btn';
168
  recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
169
  recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
170
+ recBtn.onclick = toggleRecPanel;
171
  controls.appendChild(recBtn);
172
 
173
+ // Recorder panel
174
  const inlineRec = document.createElement('div');
175
  inlineRec.id = 'vtv-inline-rec';
176
  inlineRec.className = 'vtv-inline-rec';
 
196
  `;
197
  controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
198
 
199
+ // Post-record panel
200
  const recPanel = document.createElement('div');
201
  recPanel.id = 'vtv-rec-panel';
202
  recPanel.className = 'vtv-rec-panel';
 
214
  <button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button>
215
  <button class="rec-share" onclick="window._vtvRecShare()">🎬 Tạo Short AI</button>
216
  </div>
217
+ <div class="rec-proc" id="vtv-rec-proc">
218
  <div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div>
219
  <span id="vtv-rec-proc-text">Đang xử lý...</span>
220
  </div>
 
223
 
224
  // Timeline click
225
  document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
226
+ if (!_rec.isRecording) handleRecBarClick(e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  });
228
 
229
  // Drag markers
230
+ document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
231
+ document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
232
+ document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
233
+ document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
 
 
 
 
 
234
  }
235
 
236
+ // Global drag
237
  document.addEventListener('mousemove', function(e) {
238
+ if (!_rec._dragMarker) return;
239
+ dragMarker(e.clientX);
240
+ });
241
+ document.addEventListener('touchmove', function(e) {
242
+ if (!_rec._dragMarker) return;
243
+ dragMarker(e.touches[0].clientX);
244
+ }, {passive:false});
245
+ document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
246
+ document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
247
+
248
+ function dragMarker(clientX) {
249
  const bar = document.getElementById('vtv-rec-bar');
250
  if (!bar) return;
251
  const rect = bar.getBoundingClientRect();
252
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
253
  const time = pct * 60;
254
+ if (_rec._dragMarker === 'start') {
255
+ if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
256
+ _rec.startTime = time;
257
+ const mk = document.getElementById('vtv-rec-mk-start');
258
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
 
 
259
  document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
260
  } else {
261
+ if (_rec.startTime !== null && time <= _rec.startTime + 1) return;
262
+ _rec.endTime = time;
263
+ const mk = document.getElementById('vtv-rec-mk-end');
264
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
265
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
266
  }
267
+ updateRecBar();
268
+ }
269
+
270
+ function handleRecBarClick(e) {
271
+ const video = document.getElementById('vtv-player');
272
+ if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
273
  const bar = document.getElementById('vtv-rec-bar');
 
274
  const rect = bar.getBoundingClientRect();
275
+ const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
276
  const time = pct * 60;
277
+ if (_rec.startTime === null) {
278
+ _rec.startTime = time;
279
+ const mk = document.getElementById('vtv-rec-mk-start');
280
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
 
 
281
  document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
282
+ document.getElementById('vtv-rec-set-end').disabled = false;
283
+ recStatus('Điểm đầu: ' + fmtSec(time) + '. Chọn điểm cuối.', '');
284
+ } else if (_rec.endTime === null) {
285
+ if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
286
+ const dur = time - _rec.startTime;
287
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
288
+ _rec.endTime = time;
289
+ const mk = document.getElementById('vtv-rec-mk-end');
290
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
291
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
292
+ updateRecBar();
293
+ recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
294
+ document.getElementById('vtv-rec-go').disabled = false;
295
  }
296
+ }
 
 
 
297
 
298
+ function updateRecBar() {
299
+ const s = _rec.startTime || 0, e = _rec.endTime || 60;
 
300
  const dur = e - s;
301
  document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
302
  document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
303
  document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
304
  }
305
 
306
+ // ===== RECORDER CONTROLS =====
 
 
 
 
 
 
 
307
  window._vtvRecSetStart = function() {
308
  const video = document.getElementById('vtv-player');
309
  if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
310
+ _rec.startTime = video.currentTime || 0;
311
+ const pct = _rec.startTime / 60 * 100;
 
312
  const mk = document.getElementById('vtv-rec-mk-start');
313
  mk.style.display = 'block'; mk.style.left = pct + '%';
314
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
315
  document.getElementById('vtv-rec-set-end').disabled = false;
316
+ recStatus('Điểm đầu: ' + fmtSec(_rec.startTime), '');
317
  };
318
 
319
  window._vtvRecSetEnd = function() {
320
+ if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
321
  const video = document.getElementById('vtv-player');
322
  if (!video) return;
323
  const cur = video.currentTime || 0;
324
+ if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
325
+ const dur = cur - _rec.startTime;
326
  if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
327
+ _rec.endTime = cur;
328
  const pct = cur / 60 * 100;
329
  const mk = document.getElementById('vtv-rec-mk-end');
330
  mk.style.display = 'block'; mk.style.left = pct + '%';
331
  document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
332
+ updateRecBar();
333
  recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
334
  document.getElementById('vtv-rec-go').disabled = false;
335
  };
336
 
337
  window._vtvRecReset = function() {
338
+ _rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
339
+ if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
340
+ clearTimeout(_rec._recTimer);
 
341
  document.getElementById('vtv-rec-mk-start').style.display = 'none';
342
  document.getElementById('vtv-rec-mk-end').style.display = 'none';
343
  document.getElementById('vtv-rec-progress').style.width = '0%';
 
349
  document.getElementById('vtv-rec-go').disabled = true;
350
  document.getElementById('vtv-rec-panel').classList.remove('show');
351
  recStatus('Đã reset.', '');
 
352
  const btn = document.getElementById('vtv-rec-btn');
353
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
354
  };
355
 
356
+ window._vtvRecSetRatio = function(ratio) {
357
+ _rec.ratio = ratio;
358
+ document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
359
+ };
360
+
361
+ // ===== RECORD — captureStream with audio =====
362
  window._vtvRecGo = function() {
363
+ if (_rec.startTime === null || _rec.endTime === null) return;
364
+ if (_rec.isRecording) {
365
+ if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
366
+ _rec.isRecording = false;
367
+ clearTimeout(_rec._recTimer);
 
368
  const btn = document.getElementById('vtv-rec-btn');
369
  if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
370
  return;
 
373
  const video = document.getElementById('vtv-player');
374
  if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
375
 
376
+ _rec.chunks = [];
377
+ _rec.isRecording = true;
378
+
379
+ // IMPORTANT: Unmute video briefly so captureStream includes audio
380
+ const wasMuted = video.muted;
381
+ video.muted = false;
382
+
383
+ let stream;
384
+ try {
385
+ stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
386
+ if (!stream) throw new Error('captureStream not supported');
387
+ } catch(e) {
388
+ video.muted = wasMuted;
389
+ recStatus('❌ ' + e.message, 'err');
390
+ _rec.isRecording = false;
391
+ return;
392
+ }
393
+
394
+ // Check if we got audio tracks
395
+ const audioTracks = stream.getAudioTracks();
396
+ if (audioTracks.length === 0) {
397
+ console.warn('No audio in captureStream — recording video only');
398
+ }
399
 
400
  let mimeType = 'video/webm;codecs=vp9,opus';
401
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
402
  if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
403
 
404
+ try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
405
+ catch(e) {
406
+ video.muted = wasMuted;
407
+ recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
408
+ _rec.isRecording = false;
409
+ return;
410
+ }
411
+
412
+ _rec.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _rec.chunks.push(e.data); };
413
+ _rec.recorder.onstop = function() {
414
+ _rec.isRecording = false;
415
+ video.muted = wasMuted; // Restore muted state
416
+ _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
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
  document.getElementById('vtv-rec-panel').classList.add('show');
420
  const pv = document.getElementById('vtv-rec-preview-vid');
421
+ pv.src = URL.createObjectURL(_rec.blob);
422
  document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
423
+ const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
424
+ recStatus('✅ Record xong! ' + sizeMB + 'MB', 'ok');
425
+ };
426
+ _rec.recorder.onerror = e => {
427
+ _rec.isRecording = false;
428
+ video.muted = wasMuted;
429
+ recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
430
  };
 
431
 
432
+ _rec.recorder.start(1000);
433
+ video.currentTime = _rec.startTime;
434
 
435
  const btn = document.getElementById('vtv-rec-btn');
436
  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'; }
 
437
  recStatus('🔴 ĐANG RECORD...', 'recording');
438
 
439
+ const dur = (_rec.endTime - _rec.startTime) * 1000;
440
+ _rec._recTimer = setTimeout(() => {
441
+ if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
 
 
 
442
  }, dur + 500);
443
  };
444
 
445
+ // ===== DOWNLOAD =====
 
 
 
 
 
 
446
  window._vtvRecDownload = function() {
447
+ if (!_rec.blob) return;
448
+ if (_rec.ratio === 'original') {
449
+ downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
450
  } else {
451
+ processRatio(_rec.blob, _rec.ratio, 'download');
452
  }
453
  };
454
 
455
+ // ===== SHARE TO SHORT AI =====
456
  window._vtvRecShare = function() {
457
+ if (!_rec.blob) return;
458
+ if (_rec.ratio !== 'original') {
459
+ processRatio(_rec.blob, _rec.ratio, 'upload');
460
  } else {
461
+ uploadBlob(_rec.blob);
462
  }
463
  };
464
 
 
469
  recStatus('✅ Đã tải: ' + filename, 'ok');
470
  }
471
 
472
+ function showProc(text) {
473
+ document.getElementById('vtv-rec-proc').style.display = 'block';
474
+ document.getElementById('vtv-rec-proc-text').textContent = text;
475
+ }
476
+ function hideProc() {
477
+ document.getElementById('vtv-rec-proc').style.display = 'none';
478
+ }
479
+
480
+ // ===== UPLOAD =====
481
+ function uploadBlob(blob) {
482
  const formData = new FormData();
483
  formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
484
  formData.append('title', `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
485
  formData.append('source', 'vtv_inline_recorder');
486
+ showProc('⏳ Đang upload...');
487
  fetch('/api/wall', { method: 'POST', body: formData })
488
  .then(r => r.json())
489
  .then(data => {
490
+ hideProc();
491
  if (data.ok) {
492
  recStatus('✅ Đã tạo Short AI! Xem trong Tường AI.', 'ok');
493
  } else {
 
496
  }
497
  })
498
  .catch(e => {
499
+ hideProc();
500
  recStatus('⚠️ Mất kết nối. Đang tải về...', 'err');
501
  setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
502
  });
503
  }
504
 
505
+ // ===== RATIO CROP — Fixed: proper audio extraction =====
506
+ function processRatio(blob, ratio, action) {
507
+ showProc(`🔄 Đang xử lý ${ratio}...`);
508
+
509
+ // Step 1: Extract audio from original blob using AudioContext
510
+ const reader = new FileReader();
511
+ reader.onload = function() {
512
+ const arrayBuffer = reader.result;
513
+ // Try to decode audio from the blob
514
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
515
+ let audioBuffer = null;
516
+
517
+ audioCtx.decodeAudioData(arrayBuffer)
518
+ .then(ab => { audioBuffer = ab; })
519
+ .catch(e => { console.warn('Audio decode failed:', e); })
520
+ .finally(() => {
521
+ // Step 2: Render video with canvas + merge audio
522
+ renderWithRatio(blob, ratio, action, audioBuffer, audioCtx);
523
+ });
524
+ };
525
+ reader.onerror = function() {
526
+ // No audio, just render video
527
+ renderWithRatio(blob, ratio, action, null, null);
528
+ };
529
+ reader.readAsArrayBuffer(blob);
530
  }
531
 
532
+ function renderWithRatio(blob, ratio, action, audioBuffer, audioCtx) {
 
533
  const video = document.createElement('video');
534
  video.src = URL.createObjectURL(blob);
535
  video.muted = true;
 
540
  let cw, ch, sx, sy;
541
  if (ratio === '1:1') {
542
  const size = Math.min(vw, vh); cw = ch = size;
543
+ sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
544
  } else if (ratio === '9:16') {
545
  ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
546
  sx = Math.floor((vw - cw) / 2); sy = 0;
547
  } else { cw = vw; ch = vh; sx = sy = 0; }
548
 
549
+ showProc(`🔄 Render ${cw}x${ch}...`);
550
+
551
  const canvas = document.createElement('canvas');
552
  canvas.width = cw; canvas.height = ch;
553
  const ctx = canvas.getContext('2d');
554
+
555
+ // Canvas stream (video only)
556
  const canvasStream = canvas.captureStream(30);
557
 
558
+ // If we have audio, create a merged stream
559
+ let recordStream = canvasStream;
560
+ let audioSource = null;
561
+
562
+ if (audioBuffer && audioCtx) {
563
+ try {
564
+ // Create audio destination from canvas stream
565
+ const dest = audioCtx.createMediaStreamDestination();
566
+ audioSource = audioCtx.createBufferSource();
567
+ audioSource.buffer = audioBuffer;
568
+ audioSource.connect(dest);
569
+ // Merge: canvas video + decoded audio
570
+ recordStream = new MediaStream([
571
+ ...canvasStream.getVideoTracks(),
572
+ ...dest.stream.getAudioTracks()
573
+ ]);
574
+ } catch(e) {
575
+ console.warn('Audio merge failed:', e);
576
  }
577
+ }
578
+
579
+ const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
580
+ ? 'video/webm;codecs=vp9,opus'
581
+ : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
582
+ ? 'video/webm;codecs=vp8,opus'
583
+ : 'video/webm';
584
 
 
585
  let rec;
586
+ try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
587
+ catch(e) { hideProc(); recStatus('❌ Lỗi: ' + e.message, 'err'); return; }
588
 
589
  const chunks = [];
590
+ rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
591
  rec.onstop = () => {
592
+ hideProc();
 
593
  URL.revokeObjectURL(video.src);
594
+ if (chunks.length === 0) {
595
+ recStatus('❌ Render thất bại. Thử lại hoặc chọn "Gốc".', 'err');
596
+ return;
597
+ }
598
+ const out = new Blob(chunks, {type: 'video/webm'});
599
  if (action === 'download') {
600
  downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
601
  } else {
602
+ uploadBlob(out);
603
  }
604
  };
605
+ rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error||'?'), 'err'); };
606
+
607
  rec.start(1000);
608
  video.currentTime = 0;
609
+
610
+ // Start audio playback if we have it
611
+ if (audioSource) {
612
+ audioSource.start(0);
613
+ }
614
+
615
  video.play().then(() => {
616
  function draw() {
617
+ if (video.paused || video.ended) {
618
+ if (rec.state !== 'inactive') rec.stop();
619
+ return;
620
+ }
621
  ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
622
  requestAnimationFrame(draw);
623
  }
624
  draw();
625
  setTimeout(() => { if(rec.state !== 'inactive') rec.stop(); }, video.duration * 1000 + 500);
626
+ }).catch(e => { hideProc(); recStatus('❌ ' + e.message, 'err'); });
627
  };
 
 
628
 
629
+ video.onerror = function() { hideProc(); recStatus('❌ Không đọc được video.', 'err'); };
630
+ }
631
 
632
+ // ===== TOGGLE REC PANEL =====
633
+ function toggleRecPanel() {
634
  const panel = document.getElementById('vtv-inline-rec');
635
+ if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
636
+ _rec.active = !_rec.active;
637
+ panel.classList.toggle('show', _rec.active);
638
  const btn = document.getElementById('vtv-rec-btn');
639
  if (btn) {
640
+ if (_rec.active) {
641
  btn.style.background = '#3a1a1a';
642
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
643
  } else {
644
  btn.style.background = '';
645
  btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
 
646
  document.getElementById('vtv-rec-panel').classList.remove('show');
647
  }
648
  }
 
653
  const block = document.getElementById('vtv-block');
654
  if(!block) return;
655
  const header = block.querySelector('.vtv-head');
656
+ if(!header || document.getElementById('vtv-pin-btn')) return;
 
657
  const btn = document.createElement('button');
658
+ btn.id = 'vtv-pin-btn'; btn.className = 'vtv-pin-btn';
 
659
  btn.innerHTML = '📌 Ghim';
660
  btn.title = 'Ghim VTV cố định trên đầu trang';
661
  btn.onclick = function(e){
662
+ e.stopPropagation(); _vtvPinned = !_vtvPinned;
 
663
  if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
664
  else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
665
  };
 
671
  function createMiniPlayer(){
672
  if(document.getElementById('vtv-mini')) return;
673
  const mini = document.createElement('div');
674
+ mini.className = 'vtv-mini'; mini.id = 'vtv-mini';
675
+ mini.innerHTML = `<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
676
+ <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>
677
+ <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>
 
 
 
 
 
 
 
 
 
 
 
 
678
  <div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
679
  document.body.appendChild(mini);
680
+ document.getElementById('vtv-mini-pip').onclick = async function(){
681
+ const v = document.getElementById('vtv-mini-vid');
682
+ try{ if(document.pictureInPictureElement === v) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); }catch(e){}
683
+ };
684
+ document.getElementById('vtv-mini-expand').onclick = function(){ closeMiniPlayer(); if(typeof switchCat==='function') switchCat('home'); };
685
  document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
686
  document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
687
  document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
688
  }
689
+ 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; }
 
 
 
 
 
 
 
 
690
  function activateMiniPlayer(){
691
+ if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
692
+ const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
693
+ if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:10}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
694
+ else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
695
+ const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
696
+ 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(()=>{});
697
+ mini.classList.add('show'); mini.classList.remove('hidden');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
698
  }
699
 
700
  // ===== MAIN PLAYER =====
701
  async function loadAllStreams(){
702
+ if(_loading) return; _loading=true;
703
+ const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
704
+ try{const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)}); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u=i.stream_url; if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u); STREAMS[ch.id]=[u];} else STREAMS[ch.id]=[];});}}catch(e){}
705
+ 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+' ✕';}}});
706
+ _loading=false; _streamsLoaded=true;
 
 
 
 
707
  }
708
 
709
  function buildBlock(){
710
+ const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
711
+ let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
712
+ w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
713
+ '<div class="vtv-tabs">'+tabs+'</div>'+
714
+ '<div class="vtv-frame"><div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div><video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video><div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div></div>'+
715
+ '<div class="vtv-controls"><button class="vtv-pip-btn" id="vtv-pip-btn" onclick="window._vtvTogglePiP()"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="12" y="10" width="8" height="6" rx="1" fill="currentColor"/></svg>PiP</button><span style="flex:1"></span><span style="font-size:9px;color:#666" id="vtv-status"></span></div>'+
716
+ '<div class="vtv-epg" id="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" id="vtv-epg-toggle" onclick="window._vtvToggleEpg()">Ẩn</button></div><div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div></div>';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
717
  return w;
718
  }
719
 
720
  async function loadEpg(chId){
721
+ const el=document.getElementById('vtv-epg-list'); if(!el) return;
722
+ el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
723
+ 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>';}
 
 
 
 
 
 
 
 
 
 
724
  }
725
 
726
+ 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';}};
727
+ 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){}};
 
 
 
 
 
 
 
 
 
 
 
 
728
 
729
  function pinBlock(){
730
+ const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
731
+ h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
732
+ setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
733
+ if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
 
 
734
  }
735
 
736
+ window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
737
 
738
+ window._vtvPlay=function(chId){
739
+ const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
 
740
  document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
741
+ const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
742
+ 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');
 
 
743
  video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
744
+ loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
745
+ if(_hls){_hls.destroy();_hls=null;}
746
+ const urls=STREAMS[chId]||[];
747
+ if(!urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';return;}
748
+ _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
749
  loadEpg(chId);
750
+ 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(()=>{});}
751
  };
752
 
753
+ function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
754
+ if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
755
+ const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
 
756
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
757
+ const hls=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:20}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
758
+ hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
759
+ 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);}}});
760
+ }else if(video.canPlayType('application/vnd.apple.mpegurl')){
761
+ video.src=src;
762
+ video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
 
 
763
  video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
764
+ }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
765
  }
766
 
767
+ // ===== NAV HOOKS =====
768
+ const _origShowView=window.showView;
769
+ 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);};
770
+ 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);};}
771
+ 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);};}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772
 
773
+ const _origLoadHome=window.loadHome;
774
+ if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
775
+ 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;};
776
+ window.loadHome.__vtvWrapped=true;
777
+ }
778
  })();