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

Upload static/yt_live.js

Browse files
Files changed (1) hide show
  1. static/yt_live.js +533 -170
static/yt_live.js CHANGED
@@ -1,6 +1,6 @@
1
  // === VNEWS — VTV1-VTV10 + VTVPrime LIVE CHANNELS ===
2
  // Default channel: VTV6
3
- // Features: PiP button, floating mini-player when reading articles/videos
4
  // Fixed: VTV stays at top ONLY when user clicks pin button
5
 
6
  (function(){
@@ -31,7 +31,7 @@
31
  let _streamsLoaded = false;
32
  let _pipActive = false;
33
  let _miniActive = false;
34
- let _vtvPinned = false; // only true when user clicks pin
35
 
36
  // ===== STYLES =====
37
  const style = document.createElement('style');
@@ -95,6 +95,47 @@
95
  .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
96
  .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  /* ===== MINI PLAYER ===== */
99
  .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}
100
  .vtv-mini.show{display:block}
@@ -114,6 +155,453 @@
114
  `;
115
  document.head.appendChild(style);
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  // ===== PIN BUTTON =====
118
  function setupPinButton(){
119
  const block = document.getElementById('vtv-block');
@@ -121,7 +609,6 @@
121
  const header = block.querySelector('.vtv-head');
122
  if(!header) return;
123
  if(document.getElementById('vtv-pin-btn')) return;
124
-
125
  const btn = document.createElement('button');
126
  btn.id = 'vtv-pin-btn';
127
  btn.className = 'vtv-pin-btn';
@@ -130,15 +617,8 @@
130
  btn.onclick = function(e){
131
  e.stopPropagation();
132
  _vtvPinned = !_vtvPinned;
133
- if(_vtvPinned){
134
- block.classList.add('vtv-sticky');
135
- btn.classList.add('pinned');
136
- btn.innerHTML = '📌 Đã ghim';
137
- } else {
138
- block.classList.remove('vtv-sticky');
139
- btn.classList.remove('pinned');
140
- btn.innerHTML = '📌 Ghim';
141
- }
142
  };
143
  header.style.position = 'relative';
144
  header.appendChild(btn);
@@ -175,106 +655,57 @@
175
 
176
  function closeMiniPlayer(){
177
  const mini = document.getElementById('vtv-mini');
178
- if(mini){
179
- const v = document.getElementById('vtv-mini-vid');
180
- if(v){ v.pause(); v.src = ''; }
181
- mini.remove();
182
- }
183
  _miniActive = false;
184
  }
185
 
186
- function expandFromMini(){
187
- closeMiniPlayer();
188
- if(typeof switchCat === 'function') switchCat('home');
189
- }
190
 
191
  function activateMiniPlayer(){
192
  if(!_currentCh) return;
193
- createMiniPlayer();
194
- _miniActive = true;
195
  const mini = document.getElementById('vtv-mini');
196
  const miniVid = document.getElementById('vtv-mini-vid');
197
  const mainVid = document.getElementById('vtv-player');
198
-
199
  if(_hls){
200
  const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:10});
201
- hls.loadSource(STREAMS[_currentCh][0]);
202
- hls.attachMedia(miniVid);
203
- miniVid.play().catch(()=>{});
204
- } else if(mainVid && mainVid.src){
205
- miniVid.src = mainVid.src;
206
- miniVid.play().catch(()=>{});
207
- }
208
-
209
- const ch = CHANNELS.find(c=>c.id===_currentCh);
210
- if(ch) document.getElementById('vtv-mini-ch').textContent = ch.name;
211
- updateMiniEpg();
212
- mini.classList.add('show');
213
- mini.classList.remove('hidden');
214
  }
215
 
216
  function updateMiniEpg(){
217
  if(!_currentCh) return;
218
  fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{
219
- const progs = d.programs||[];
220
- const now = progs.find(p=>p.now);
221
  document.getElementById('vtv-mini-epg').textContent = now ? now.time+' '+now.title : (progs[0] ? progs[0].time+' '+progs[0].title : '');
222
  }).catch(()=>{});
223
  }
224
 
225
  async function toggleMiniPiP(){
226
- const v = document.getElementById('vtv-mini-vid');
227
- if(!v) return;
228
- try{
229
- if(document.pictureInPictureElement === v){
230
- await document.exitPictureInPicture();
231
- } else {
232
- await v.requestPictureInPicture();
233
- }
234
- } catch(e){ alert('Không hỗ trợ PiP'); }
235
  }
236
 
237
  // ===== MAIN PLAYER =====
238
  async function loadAllStreams(){
239
- if(_loading) return;
240
- _loading = true;
241
  const el = document.getElementById('vtv-load');
242
  if(el) el.innerHTML = '<div class="vtv-spinner"></div>Đang tải kênh...';
243
  try{
244
  const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
245
- if(r.ok){
246
- const data = await r.json();
247
- CHANNELS.forEach(ch=>{
248
- const info = data[ch.id];
249
- if(info && info.stream_url){
250
- let url = info.stream_url;
251
- if(NEEDS_PROXY.test(url)) url = '/api/proxy/m3u8/vtv?url='+encodeURIComponent(url);
252
- STREAMS[ch.id] = [url];
253
- } else {
254
- STREAMS[ch.id] = [];
255
- }
256
- });
257
- }
258
  } catch(e){ console.warn('VTV API error:', e); }
259
- CHANNELS.forEach(ch=>{
260
- const tab = document.getElementById('vtvt-'+ch.id);
261
- if(tab){
262
- if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){ tab.classList.remove('off'); tab.textContent=ch.name; }
263
- else { tab.style.opacity='0.35'; tab.textContent=ch.name+' ✕'; }
264
- }
265
- });
266
- _loading = false;
267
- _streamsLoaded = true;
268
  }
269
 
270
  function buildBlock(){
271
  const w = document.createElement('div');
272
- w.className = 'vtv-wrap';
273
- w.id = 'vtv-block';
274
  let tabs = '';
275
- CHANNELS.forEach(ch=>{
276
- tabs += '<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';
277
- });
278
  w.innerHTML =
279
  '<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>' +
280
  '<div class="vtv-tabs">'+tabs+'</div>' +
@@ -298,26 +729,19 @@
298
  }
299
 
300
  async function loadEpg(chId){
301
- const el = document.getElementById('vtv-epg-list');
302
- if(!el) return;
303
  el.innerHTML = '<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
304
  try{
305
  const r = await fetch('/api/vtv/epg/'+chId, {signal: AbortSignal.timeout(10000)});
306
  if(!r.ok) throw new Error('fail');
307
- const data = await r.json();
308
- const progs = data.programs||[];
309
  if(!progs.length){ el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch phát sóng</div>'; return; }
310
  let h = '';
311
- progs.forEach(p=>{
312
- const nw = p.now ? ' now' : '';
313
- 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>';
314
- });
315
  el.innerHTML = h;
316
  const nowItem = el.querySelector('.vtv-epg-item.now');
317
  if(nowItem) nowItem.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});
318
- } catch(e){
319
- el.innerHTML = '<div class="vtv-epg-empty">Không tải được lịch</div>';
320
- }
321
  }
322
 
323
  window._vtvToggleEpg = function(){
@@ -328,19 +752,10 @@
328
  };
329
 
330
  window._vtvTogglePiP = async function(){
331
- const v = document.getElementById('vtv-player');
332
- const btn = document.getElementById('vtv-pip-btn');
333
- if(!v) return;
334
  try{
335
- if(document.pictureInPictureElement === v){
336
- await document.exitPictureInPicture();
337
- _pipActive = false;
338
- if(btn) btn.classList.remove('on');
339
- } else {
340
- await v.requestPictureInPicture();
341
- _pipActive = true;
342
- if(btn) btn.classList.add('on');
343
- }
344
  } catch(e){ alert('Không hỗ trợ PiP'); }
345
  };
346
 
@@ -349,112 +764,58 @@
349
  if(!h||document.getElementById('vtv-block')) return;
350
  h.insertBefore(buildBlock(), h.firstChild);
351
  _blockInserted = true;
352
- // Setup pin button after block is in DOM
353
- setTimeout(()=>setupPinButton(), 500);
354
- if(!_streamsLoaded){
355
- loadAllStreams().then(()=>{
356
- setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL), 300);
357
- });
358
- }
359
  }
360
 
361
  window._vtvRetry = function(){ if(_currentCh) window._vtvPlay(_currentCh); };
362
 
363
  window._vtvPlay = function(chId){
364
- const ch = CHANNELS.find(c=>c.id===chId);
365
- if(!ch) return;
366
  _currentCh = chId;
367
  document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
368
- const tab = document.getElementById('vtvt-'+chId);
369
- if(tab) tab.classList.add('on');
370
  const video = document.getElementById('vtv-player');
371
- const errEl = document.getElementById('vtv-err');
372
- const loadEl = document.getElementById('vtv-load');
373
  const errMsg = document.getElementById('vtv-err-msg');
374
- video.style.display='none';
375
- errEl.style.display='none';
376
- loadEl.style.display='flex';
377
  loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
378
  if(_hls){_hls.destroy(); _hls=null;}
379
  const urls = STREAMS[chId]||[];
380
- if(!urls.length){
381
- loadEl.style.display='none'; errEl.style.display='flex';
382
- errMsg.textContent = chId==='vtvprime' ? 'VTVPrime: Kênh trả phí.' : ch.name+': Không có luồng.';
383
- return;
384
- }
385
  _tryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
386
  loadEpg(chId);
387
- if(_miniActive){
388
- document.getElementById('vtv-mini-ch').textContent = ch.name;
389
- updateMiniEpg();
390
- }
391
  };
392
 
393
  function _tryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
394
- if(idx>=urls.length){
395
- loadEl.style.display='none'; errEl.style.display='flex';
396
- errMsg.textContent = name+': Tất cả nguồn lỗi.';
397
- return;
398
- }
399
  const src = urls[idx];
400
  loadEl.innerHTML = '<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
401
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
402
  const hls = new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:20});
403
- _hls = hls;
404
- hls.loadSource(src);
405
- hls.attachMedia(video);
406
  hls.on(Hls.Events.MANIFEST_PARSED,()=>{ video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; });
407
  let rec = 0;
408
- hls.on(Hls.Events.ERROR,(ev,data)=>{
409
- if(data.fatal){
410
- if(data.type===Hls.ErrorTypes.NETWORK_ERROR){
411
- rec++;
412
- if(rec<=3) setTimeout(()=>hls.startLoad(), 2000);
413
- else { hls.destroy(); _hls=null; _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg); }
414
- } else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){
415
- try{hls.recoverMediaError();}catch(e){}
416
- } else { hls.destroy(); _hls=null; _tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg); }
417
- }
418
- });
419
  } else if(video.canPlayType('application/vnd.apple.mpegurl')){
420
  video.src = src;
421
  video.addEventListener('loadedmetadata',()=>{ video.play().catch(()=>{}); loadEl.style.display='none'; video.style.display='block'; },{once:true});
422
  video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
423
- } else {
424
- loadEl.style.display='none'; errEl.style.display='flex'; errMsg.textContent='Không hỗ trợ HLS';
425
- }
426
  }
427
 
428
  // ===== NAVIGATION HOOKS =====
429
  const _origShowView = window.showView;
430
  window.showView = function(id){
431
- if(id==='view-home' && _miniActive){
432
- closeMiniPlayer();
433
- } else if(id!=='view-home' && _currentCh && STREAMS[_currentCh] && STREAMS[_currentCh].length>0 && !_miniActive){
434
- activateMiniPlayer();
435
- }
436
  return _origShowView.apply(this, arguments);
437
  };
438
-
439
  const _origReadArticle = window.readArticle;
440
- if(_origReadArticle){
441
- window.readArticle = function(url){
442
- if(_currentCh && STREAMS[_currentCh] && STREAMS[_currentCh].length>0 && !_miniActive) activateMiniPlayer();
443
- return _origReadArticle.apply(this, arguments);
444
- };
445
- }
446
-
447
  const _origSwitchCat = window.switchCat;
448
- if(_origSwitchCat){
449
- window.switchCat = function(id){
450
- if(id==='home'){
451
- if(_miniActive) closeMiniPlayer();
452
- } else if(_currentCh && STREAMS[_currentCh] && STREAMS[_currentCh].length>0 && !_miniActive){
453
- activateMiniPlayer();
454
- }
455
- return _origSwitchCat.apply(this, arguments);
456
- };
457
- }
458
 
459
  // Override loadHome to inject VTV block
460
  const _origLoadHome = window.loadHome;
@@ -469,4 +830,6 @@
469
  };
470
  window.loadHome.__vtvWrapped = true;
471
  }
 
 
472
  })();
 
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(){
 
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');
 
95
  .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
96
  .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
97
 
98
+ /* ===== INLINE RECORDER ===== */
99
+ .vtv-rec-btn{background:#660000;border:1px solid #990000;color:#ff6666;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px;font-weight:700}
100
+ .vtv-rec-btn:hover{background:#990000;color:#fff}
101
+ .vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
102
+ @keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
103
+ .vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
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}
113
+ .vtv-inline-rec .rec-controls{display:flex;gap:4px}
114
+ .vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
115
+ .vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
116
+ .vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
117
+ .vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
118
+ .vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
119
+ .vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
120
+ .vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
121
+ .vtv-inline-rec .rec-status.ok{color:#2ecc71}
122
+ .vtv-inline-rec .rec-status.err{color:#e74c3c}
123
+ .vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
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}
131
+ .vtv-rec-panel .rec-actions{display:flex;gap:4px}
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}
141
  .vtv-mini.show{display:block}
 
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';
191
+ inlineRec.innerHTML = `
192
+ <div class="rec-time">
193
+ <span id="vtv-rec-time-start">00:00</span>
194
+ <span id="vtv-rec-time-dur">Duration: 0s</span>
195
+ <span id="vtv-rec-time-end">01:00</span>
196
+ </div>
197
+ <div class="rec-bar" id="vtv-rec-bar">
198
+ <div class="rec-progress" id="vtv-rec-progress"></div>
199
+ <div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
200
+ <div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
201
+ </div>
202
+ <div class="rec-controls">
203
+ <button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button>
204
+ <button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button>
205
+ <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
206
+ <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
207
+ </div>
208
+ <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
209
+ <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
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';
217
+ recPanel.innerHTML = `
218
+ <div class="rec-panel-title">🎬 Đã record xong!</div>
219
+ <div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
220
+ <video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
221
+ </div>
222
+ <div class="rec-ratio-row">
223
+ <button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
224
+ <button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
225
+ <button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button>
226
+ </div>
227
+ <div class="rec-actions">
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>
235
+ `;
236
+ inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
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%';
383
+ document.getElementById('vtv-rec-time-start').textContent = '00:00';
384
+ document.getElementById('vtv-rec-time-end').textContent = '01:00';
385
+ document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
386
+ document.getElementById('vtv-rec-set-start').disabled = false;
387
+ document.getElementById('vtv-rec-set-end').disabled = true;
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;
406
+ }
407
+
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
+
481
+ function downloadBlob(blob, filename) {
482
+ const a = document.createElement('a');
483
+ a.href = URL.createObjectURL(blob); a.download = filename; a.click();
484
+ URL.revokeObjectURL(a.href);
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 {
501
+ recStatus('⚠️ ' + (data.error || 'Upload lỗi') + '. Đang tải về...', 'err');
502
+ setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
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;
525
+ video.preload = 'auto';
526
+
527
+ video.onloadedmetadata = function() {
528
+ const vw = video.videoWidth, vh = video.videoHeight;
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
+ }
603
+ }
604
+
605
  // ===== PIN BUTTON =====
606
  function setupPinButton(){
607
  const block = document.getElementById('vtv-block');
 
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';
 
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
  };
623
  header.style.position = 'relative';
624
  header.appendChild(btn);
 
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>' +
 
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(){
 
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
 
 
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;
 
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
  })();