bep40 commited on
Commit
fad3e19
·
verified ·
1 Parent(s): f34d700

Upload static/yt_live.js

Browse files
Files changed (1) hide show
  1. static/yt_live.js +776 -252
static/yt_live.js CHANGED
@@ -1,6 +1,6 @@
1
- // === VNEWS — VTV LIVE + Inline Recorder v15 ===
2
- // Features: DVR 5-min rewind (liveDurationInfinity), PiP, mini-player, INLINE RECORDER
3
- // v15: Use liveDurationInfinity:true HLS.js treats live as VOD, no auto-snap. User seeks freely in buffer.
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
@@ -14,303 +14,835 @@
14
  {id:'vtv9',name:'VTV9',badge:'Miền Bắc'},{id:'vtv10',name:'VTV10',badge:'Cần Thơ'},
15
  {id:'vtvprime',name:'VTVPrime',badge:'Prime'},
16
  ];
17
- const DEFAULT_CHANNEL = 'vtv6';
18
- const DVR_BUFFER_SECONDS = 300;
19
  const NEEDS_PROXY = /fptplay\.net/;
20
  const STREAMS = {};
21
  let _currentCh = null, _hls = null, _loading = false, _blockInserted = false;
22
  let _streamsLoaded = false, _pipActive = false, _miniActive = false, _vtvPinned = false;
23
- let _dvrSeekBarTimer = null;
24
- let _dvrUserSeeking = false;
25
- let _dvrAtLiveEdge = true;
26
 
 
27
  const _rec = {
28
  active: false, startTime: null, endTime: null,
29
  isRecording: false, recorder: null, chunks: [], blob: null,
30
- stream: null, tracks: [], ratio: '16:9', aiTitle: null, aiTags: [],
31
- _ratioCrop: null, _aiAborted: false
32
  };
33
 
34
- function fmtSec(s){const m=Math.floor(s/60),sec=Math.floor(s%60);return m+':'+(sec<10?'0':'')+sec;}
35
- function $(id){return document.getElementById(id);}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- async function loadAllStreams(){
38
- if(_streamsLoaded) return;
39
- _streamsLoaded = true;
40
- try{
41
- const r=await fetch('/api/vtv/streams');
42
- if(!r.ok) return;
43
- const data=await r.json();
44
- for(const ch of CHANNELS){
45
- if(data[ch.id]&&data[ch.id].stream_url){
46
- let u=data[ch.id].stream_url;
47
- if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u);
48
- STREAMS[ch.id]=[u];
49
- } else STREAMS[ch.id]=[];
50
- }
51
- }catch(e){}
52
- CHANNELS.forEach(ch=>{
53
- const t=document.getElementById('vtvt-'+ch.id);
54
- if(t){
55
- if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off');t.textContent=ch.name;}
56
- else{t.style.opacity='0.35';t.textContent=ch.name+' ✕';}
57
- }
58
- });
59
  }
60
 
61
- function createDVRBar(){
62
- const player=$('vtv-player-wrap');
63
- if(!player) return;
64
- const old=$('vtv-dvr-bar');
65
- if(old) old.remove();
66
- const bar=document.createElement('div');
67
- bar.id='vtv-dvr-bar';
68
- bar.innerHTML='<div class="vtv-dvr-track"><div class="vtv-dvr-buffer" id="vtv-dvr-buffer"></div><div class="vtv-dvr-live-dot" id="vtv-dvr-live-dot"></div><div class="vtv-dvr-handle" id="vtv-dvr-handle"></div></div><div class="vtv-dvr-controls"><span class="vtv-dvr-time" id="vtv-dvr-time">LIVE</span><button class="vtv-dvr-live-btn" id="vtv-dvr-live-btn">● LIVE</button></div>';
69
- player.appendChild(bar);
70
- return bar;
71
  }
72
 
73
- function updateDVRUI(){
74
- const video=$('vtv-player');
75
- if(!video||!_hls) return;
76
- const track=$('vtv-dvr-track');
77
- const buffer=$('vtv-dvr-buffer');
78
- const handle=$('vtv-dvr-handle');
79
- const liveDot=$('vtv-dvr-live-dot');
80
- const timeLabel=$('vtv-dvr-time');
81
- if(!track||!buffer||!handle) return;
82
- const curTime=video.currentTime;
83
- const duration=video.duration;
84
- if(!duration||!isFinite(duration)||duration<=0) return;
85
- const bufferStart=Math.max(0,duration-DVR_BUFFER_SECONDS);
86
- const bufferWidth=((duration-bufferStart)/DVR_BUFFER_SECONDS)*100;
87
- const handlePos=((curTime-bufferStart)/(duration-bufferStart))*100;
88
- buffer.style.width=Math.min(100,bufferWidth)+'%';
89
- buffer.style.left=(100-Math.min(100,bufferWidth))+'%';
90
- liveDot.style.left='100%';
91
- handle.style.left=Math.max(0,Math.min(100,handlePos))+'%';
92
- const behind=duration-curTime;
93
- if(behind<3){timeLabel.textContent='LIVE';timeLabel.style.color='#e74c3c';}
94
- else{timeLabel.textContent='-'+fmtSec(behind);timeLabel.style.color='#8ab4d8';}
95
  }
96
 
97
- function seekFromTrack(clientX,isRelease){
98
- const video=$('vtv-player');
99
- if(!video||!_hls) return;
100
- const track=$('vtv-dvr-track');
101
- if(!track) return;
102
- const rect=track.getBoundingClientRect();
103
- const pct=Math.max(0,Math.min(1,(clientX-rect.left)/rect.width));
104
- const duration=video.duration;
105
- if(!duration||!isFinite(duration)||duration<=0) return;
106
- const bufferStart=Math.max(0,duration-DVR_BUFFER_SECONDS);
107
- const seekTime=bufferStart+pct*(duration-bufferStart);
108
- _dvrUserSeeking=!isRelease;
109
- _dvrAtLiveEdge=(duration-seekTime<2);
110
- video.currentTime=seekTime;
111
- if(isRelease){video.play().catch(()=>{});}
112
- else{video.pause();}
113
- updateDVRUI();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
115
 
116
- function setupDVRBar(){
117
- const video=$('vtv-player');
118
- if(!video) return;
119
- createDVRBar();
120
- const track=$('vtv-dvr-track');
121
- const handle=$('vtv-dvr-handle');
122
- const liveBtn=$('vtv-dvr-live-btn');
123
- if(!track||!handle) return;
124
- let isDragging=false;
125
- track.addEventListener('click',function(e){seekFromTrack(e.clientX,true);});
126
- track.addEventListener('touchstart',function(e){e.preventDefault();seekFromTrack(e.touches[0].clientX,false);},{passive:false});
127
- track.addEventListener('touchend',function(e){e.preventDefault();seekFromTrack(e.changedTouches[0].clientX,true);},{passive:false});
128
- handle.addEventListener('mousedown',function(e){e.preventDefault();e.stopPropagation();isDragging=true;_dvrUserSeeking=true;});
129
- handle.addEventListener('touchstart',function(e){e.preventDefault();e.stopPropagation();isDragging=true;_dvrUserSeeking=true;},{passive:false});
130
- document.addEventListener('mousemove',function(e){if(isDragging){e.preventDefault();seekFromTrack(e.clientX,false);}});
131
- document.addEventListener('touchmove',function(e){if(isDragging){e.preventDefault();seekFromTrack(e.touches[0].clientX,false);}},{passive:false});
132
- document.addEventListener('mouseup',function(e){if(isDragging){isDragging=false;_dvrUserSeeking=false;seekFromTrack(e.clientX,true);}});
133
- document.addEventListener('touchend',function(e){if(isDragging){isDragging=false;_dvrUserSeeking=false;if(e.changedTouches[0])seekFromTrack(e.changedTouches[0].clientX,true);}});
134
- liveBtn.addEventListener('click',function(){
135
- const v=$('vtv-player');
136
- if(v&&_hls){
137
- _dvrUserSeeking=false;_dvrAtLiveEdge=true;
138
- const dur=v.duration;
139
- if(dur&&isFinite(dur)&&dur>0){v.currentTime=dur;}
140
- v.play().catch(()=>{});
141
- updateDVRUI();
142
- }
143
  });
144
- if(_dvrSeekBarTimer) clearInterval(_dvrSeekBarTimer);
145
- _dvrSeekBarTimer=setInterval(updateDVRUI,1000);
146
- video.addEventListener('timeupdate',function(){
147
- if(!_hls) return;
148
- const dur=video.duration;
149
- if(!dur||!isFinite(dur)) return;
150
- _dvrAtLiveEdge=((dur-video.currentTime)<2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  });
 
 
 
 
 
152
  }
153
 
154
- function showDVRBar(){const bar=$('vtv-dvr-bar');if(bar) bar.classList.add('visible');}
 
 
 
 
155
 
156
- function setupPinButton(){
157
- const block=document.getElementById('vtv-block');
158
- if(!block) return;
159
- let pinBtn=block.querySelector('.vtv-pin-btn');
160
- if(!pinBtn){
161
- pinBtn=document.createElement('button');
162
- pinBtn.className='vtv-pin-btn';pinBtn.textContent='📌';pinBtn.title='Ghim khung xem';
163
- block.style.position='relative';block.appendChild(pinBtn);
 
 
 
 
 
 
 
 
 
 
164
  }
165
- pinBtn.onclick=function(){
166
- _vtvPinned=!_vtvPinned;
167
- pinBtn.style.color=_vtvPinned?'#e74c3c':'#8ab4d8';
168
- if(_vtvPinned){block.style.position='fixed';block.style.bottom='12px';block.style.right='12px';block.style.width='360px';block.style.zIndex='9999';block.style.borderRadius='12px';block.style.boxShadow='0 4px 24px rgba(0,0,0,0.5)';}
169
- else{block.style.position='';block.style.bottom='';block.style.right='';block.style.width='';block.style.zIndex='';block.style.borderRadius='';block.style.boxShadow='';}
170
- };
171
  }
172
 
173
- async function togglePiP(){
174
- const video=$('vtv-player');
175
- if(!video) return;
176
- try{
177
- if(document.pictureInPictureElement===video){await document.exitPictureInPicture();_pipActive=false;}
178
- else{await video.requestPictureInPicture();_pipActive=true;}
179
- }catch(e){}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
181
 
182
- function activateMiniPlayer(){
183
- const video=$('vtv-player');
184
- if(!video||!video.src) return;
185
- _miniActive=true;
186
- const mini=document.createElement('div');
187
- mini.id='vtv-mini-player';
188
- mini.innerHTML='<div class="vtv-mini-header"><span id="vtv-mini-ch">VTV</span><span id="vtv-mini-epg"></span><button id="vtv-mini-close" style="background:none;border:none;color:#e74c3c;font-size:18px;cursor:pointer;">✕</button></div><div class="vtv-mini-body"><div class="vtv-mini-loading">Đang tải...</div></div>';
189
- document.body.appendChild(mini);
190
- const mv=mini.querySelector('.vtv-mini-body');
191
- video.style.display='none';mv.appendChild(video);
192
- video.style.display='block';video.style.width='100%';video.style.height='100%';
193
- mini.querySelector('.vtv-mini-loading').remove();
194
- $('vtv-mini-close').onclick=closeMiniPlayer;
195
- if(_currentCh){fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);$('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
196
  }
197
 
198
- function closeMiniPlayer(){
199
- if(!_miniActive) return;
200
- _miniActive=false;
201
- const mini=$('vtv-mini-player');
202
- if(!mini) return;
203
- const video=$('vtv-player');
204
- if(video){mini.querySelector('.vtv-mini-body').appendChild(video);video.style.display='block';}
205
- mini.remove();
206
- const wrap=$('vtv-player-wrap');
207
- if(wrap&&video){wrap.appendChild(video);video.style.display='block';}
208
- }
 
209
 
210
- function initRecorder(){
211
- const block=document.getElementById('vtv-block');
212
- if(!block) return;
213
- let recBtn=block.querySelector('.vtv-rec-btn');
214
- if(!recBtn){
215
- recBtn=document.createElement('button');
216
- recBtn.className='vtv-rec-btn';recBtn.textContent='⏺ REC';recBtn.title='Quay màn hình';
217
- block.style.position='relative';block.appendChild(recBtn);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  }
219
- recBtn.onclick=function(){
220
- if(!_rec.active){
221
- _rec.active=true;recBtn.style.color='#e74c3c';recBtn.textContent=' STOP';
222
- _rec.chunks=[];_rec.blob=null;
223
- const video=$('vtv-player');
224
- if(video&&video.captureStream){
225
- _rec.stream=video.captureStream();_rec.tracks=_rec.stream.getTracks();
226
- try{
227
- _rec.recorder=new MediaRecorder(_rec.stream,{mimeType:'video/webm;codecs=vp9'});
228
- _rec.recorder.ondataavailable=function(e){if(e.data.size>0)_rec.chunks.push(e.data);};
229
- _rec.recorder.onstop=function(){_rec.blob=new Blob(_rec.chunks,{type:'video/webm'});_rec.chunks=[];_rec.tracks.forEach(t=>t.stop());_rec.stream=null;showRecordPreview(_rec.blob);};
230
- _rec.recorder.start(1000);_rec.isRecording=true;_rec.startTime=Date.now();
231
- }catch(e){_rec.tracks.forEach(t=>t.stop());_rec.stream=null;_rec.active=false;recBtn.style.color='';recBtn.textContent='⏺ REC';}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  }
233
- }else{
234
- if(_rec.isRecording&&_rec.recorder&&_rec.recorder.state==='recording'){_rec.recorder.stop();_rec.isRecording=false;_rec.endTime=Date.now();}
235
- _rec.active=false;recBtn.style.color='';recBtn.textContent='⏺ REC';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
 
240
- function showRecordPreview(blob){
241
- const url=URL.createObjectURL(blob);
242
- const div=document.createElement('div');
243
- div.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;';
244
- div.innerHTML='<video src="'+url+'" controls style="max-width:80%;max-height:60%;border-radius:8px;"></video><div style="display:flex;gap:8px;"><button id="rec-upload" style="padding:8px 16px;background:#2ecc71;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">📤 Upload</button><button id="rec-close" style="padding:8px 16px;background:#e74c3c;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;">✕ Đóng</button></div>';
245
- document.body.appendChild(div);
246
- $('rec-close').onclick=function(){URL.revokeObjectURL(url);div.remove();};
247
- $('rec-upload').onclick=function(){const v=div.querySelector('video');v.pause();uploadRecording(blob,div,url);};
 
248
  }
249
 
250
- async function uploadRecording(blob,div,url){
251
- const btn=$('rec-upload');btn.textContent='⏳ Đang upload...';btn.disabled=true;
252
- try{const fd=new FormData();fd.append('file',blob,'recording.webm');const r=await fetch('/api/upload',{method:'POST',body:fd});const d=await r.json();if(d.ok){btn.textContent='✅ Upload thành công!';setTimeout(()=>{URL.revokeObjectURL(url);div.remove();},1500);}else{btn.textContent='❌ Lỗi: '+(d.error||'unknown');btn.disabled=false;}}catch(e){btn.textContent='❌ Lỗi kết nối';btn.disabled=false;}
 
253
  }
254
 
255
- function switchChannel(chId){
256
- if(_loading) return;
257
- _loading=true;
258
- const ch=CHANNELS.find(c=>c.id===chId);
259
- if(!ch){_loading=false;return;}
260
- _currentCh=chId;
261
- const video=$('vtv-player');
262
- const loadEl=$('vtv-loading');
263
- const errEl=$('vtv-error');
264
- const errMsg=$('vtv-error-msg');
265
- if(!video){_loading=false;return;}
266
- video.style.display='none';
267
- if(loadEl) loadEl.style.display='flex';
268
- if(errEl) errEl.style.display='none';
 
 
 
 
 
269
  if(_hls){_hls.destroy();_hls=null;}
270
- _dvrAtLiveEdge=true;
271
- const oldBar=$('vtv-dvr-bar');
272
- if(oldBar) oldBar.remove();
273
- if(_dvrSeekBarTimer){clearInterval(_dvrSeekBarTimer);_dvrSeekBarTimer=null;}
274
  const urls=STREAMS[chId]||[];
275
- if(!urls.length){if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';}_loading=false;return;}
276
  _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
277
  loadEpg(chId);
278
- if(_miniActive){$('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);$('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
279
- setTimeout(()=>{setupDVRBar();showDVRBar();},2000);
280
- _loading=false;
281
- }
282
-
283
- function loadEpg(chId){
284
- fetch('/api/vtv/epg/'+chId).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);const el=$('vtv-epg-now');if(el) el.textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});
285
- }
286
 
287
  function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
288
- if(idx>=urls.length){if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';}return;}
289
- const src=urls[idx];
290
- if(loadEl) loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
291
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
292
- const hls=new Hls({
293
- enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,
294
- maxBufferLength:120,maxMaxBufferLength:180,
295
- liveDurationInfinity:true,
296
- liveBackBufferLength:DVR_BUFFER_SECONDS,
297
- fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000
298
- });
299
- _hls=hls; hls.loadSource(src); hls.attachMedia(video);
300
- hls.on(Hls.Events.MANIFEST_PARSED,(ev,data)=>{
301
- loadEl.style.display='none';video.style.display='block';
302
- const dur=video.duration;
303
- if(dur&&isFinite(dur)&&dur>0){video.currentTime=dur;}
304
- video.play().catch(()=>{});
305
- });
306
  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);}}});
307
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
308
  video.src=src;
309
  video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
310
  video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
311
- }else{if(loadEl)loadEl.style.display='none';if(errEl){errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}}
312
  }
313
 
 
314
  const _origShowView=window.showView;
315
  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);};
316
  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);};}
@@ -321,12 +853,4 @@
321
  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;};
322
  window.loadHome.__vtvWrapped=true;
323
  }
324
-
325
- window._vtvPlay=switchChannel;
326
- window._vtvPiP=togglePiP;
327
-
328
- loadAllStreams().then(()=>{setTimeout(()=>{window._vtvPlay(DEFAULT_CHANNEL);},300);});
329
-
330
- setTimeout(()=>{const pipBtn=document.getElementById('vtv-pip-btn');if(pipBtn) pipBtn.onclick=togglePiP;setupPinButton();initRecorder();},1000);
331
-
332
  })();
 
1
+ // === VNEWS — VTV LIVE + Inline Recorder v7 ===
2
+ // Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
3
+ // FIX v7: uploadToWall robust JSON parse, processRatio timeout fallback, no premature revoke
4
 
5
  (function(){
6
  if(window._ytLiveLoaded) return;
 
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 = 'vtv3';
 
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
  };
29
 
30
+ // ===== STYLES =====
31
+ const style = document.createElement('style');
32
+ style.textContent = `
33
+ .vtv-wrap{position:relative;margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
34
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
35
+ .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
36
+ .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
37
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
38
+ .vtv-tabs{display:flex;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
39
+ .vtv-tabs::-webkit-scrollbar{display:none}
40
+ .vtv-tab{padding:4px 8px;background:#1a2a3a;border:1px solid #2a3a4a;border-radius:10px;color:#8ab4d8;font-size:9px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:all .2s}
41
+ .vtv-tab:hover{background:#0b4a7a;color:#fff}
42
+ .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
43
+ .vtv-tab.off{opacity:.35;pointer-events:none}
44
+ .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
45
+ .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
46
+ .vtv-err{display:flex;align-items:center;justify-content:center;height:180px;color:#888;font-size:12px;text-align:center;padding:20px;flex-direction:column;gap:8px}
47
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
48
+ .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
49
+ .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
50
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
51
+ .vtv-controls{display:flex;align-items:center;gap:4px;padding:4px 8px;background:#0d1a2a;border-top:1px solid #1a2a3a}
52
+ .vtv-pip-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:3px 8px;border-radius:6px;cursor:pointer;transition:all .2s;display:flex;align-items:center;gap:4px}
53
+ .vtv-pip-btn:hover{background:#0b4a7a;color:#fff}
54
+ .vtv-pip-btn.on{background:#0066cc;border-color:#00ccff;color:#fff}
55
+ .vtv-pip-btn svg{width:12px;height:12px;fill:currentColor}
56
+ .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
57
+ .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
58
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
59
+ .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
60
+ .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
61
+ .vtv-epg-list::-webkit-scrollbar{display:none}
62
+ .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap;cursor:pointer}
63
+ .vtv-epg-item:hover{background:#2a4a6a}
64
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
65
+ .vtv-epg-item .t{font-size:7px;color:#6a8aaa}
66
+ .vtv-epg-item.now .t{color:#aaccee}
67
+ .vtv-epg-item .n{color:#ccc;font-size:8px}
68
+ .vtv-epg-item.now .n{color:#fff}
69
+ .vtv-epg-empty{color:#666;font-size:9px;padding:4px}
70
+ .vtv-epg-loading{color:#00ccff;font-size:9px;padding:4px;display:flex;align-items:center;gap:6px}
71
+ .vtv-epg-sp{width:10px;height:10px;border:1px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
72
+ .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}
73
+ .vtv-pin-btn:hover{background:#0b4a7a;color:#fff}
74
+ .vtv-pin-btn.pinned{background:#0066cc;border-color:#00ccff;color:#fff}
75
+ .vtv-wrap.vtv-sticky{position:sticky;top:0;z-index:48;transition:all .25s ease;box-shadow:0 4px 24px rgba(0,102,204,.35)}
76
+ .vtv-wrap.vtv-sticky .vtv-epg{display:none}
77
+ .vtv-wrap.vtv-sticky .vtv-controls{display:none}
78
+ .vtv-wrap.vtv-sticky .vtv-tabs{padding:3px 8px}
79
+ .vtv-wrap.vtv-sticky .vtv-tab{padding:3px 7px;font-size:8px}
80
+ .vtv-wrap.vtv-sticky .vtv-frame{max-height:140px;min-height:100px}
81
+ .vtv-wrap.vtv-sticky .vtv-frame video{max-height:140px}
82
+ .vtv-wrap.vtv-sticky .vtv-load{height:100px}
83
+ .vtv-wrap.vtv-sticky .vtv-err{height:100px}
84
+ .vtv-wrap.vtv-sticky .vtv-head{padding:5px 10px}
85
+ .vtv-wrap.vtv-sticky .vtv-title{font-size:11px}
86
+ .vtv-wrap.vtv-sticky .vtv-badge{font-size:8px}
87
+ .vtv-wrap.vtv-sticky .vtv-pin-btn{top:4px;right:6px}
88
+ .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}
89
+ .vtv-rec-btn:hover{background:#990000;color:#fff}
90
+ .vtv-rec-btn.recording{background:#cc0000;border-color:#ff0000;color:#fff;animation:vtv-rec-pulse 1s infinite}
91
+ @keyframes vtv-rec-pulse{0%,100%{opacity:1}50%{opacity:.5}}
92
+ .vtv-rec-btn svg{width:12px;height:12px;fill:currentColor}
93
+ .vtv-inline-rec{display:none;padding:8px 10px;background:#0a0a1a;border-top:1px solid #1a1a3a}
94
+ .vtv-inline-rec.show{display:block}
95
+ .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}
96
+ .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}
97
+ .vtv-inline-rec .rec-bar .rec-marker{position:absolute;top:0;bottom:0;width:4px;z-index:2;border-radius:2px}
98
+ .vtv-inline-rec .rec-bar .rec-marker.s{background:#2ecc71}
99
+ .vtv-inline-rec .rec-bar .rec-marker.e{background:#e74c3c}
100
+ .vtv-inline-rec .rec-time{display:flex;justify-content:space-between;font-size:9px;color:#888;margin-bottom:6px}
101
+ .vtv-inline-rec .rec-controls{display:flex;gap:4px}
102
+ .vtv-inline-rec .rec-controls button{flex:1;padding:6px;border:none;border-radius:6px;font-size:10px;font-weight:700;cursor:pointer}
103
+ .vtv-inline-rec .rec-controls .rec-set-start{background:#1a3a1a;border:1px solid #2d6a2d;color:#5cb87a}
104
+ .vtv-inline-rec .rec-controls .rec-set-end{background:#3a1a1a;border:1px solid #6a2d2d;color:#e74c3c}
105
+ .vtv-inline-rec .rec-controls .rec-go{background:#1a0a3a;border:1px solid #3a2a6a;color:#9b59b6}
106
+ .vtv-inline-rec .rec-controls .rec-reset{background:#222;border:1px solid #333;color:#888}
107
+ .vtv-inline-rec .rec-hint{font-size:9px;color:#666;text-align:center;margin-top:4px}
108
+ .vtv-inline-rec .rec-status{font-size:10px;color:#888;text-align:center;padding:4px;margin-top:4px;background:#111;border-radius:4px}
109
+ .vtv-inline-rec .rec-status.ok{color:#2ecc71}
110
+ .vtv-inline-rec .rec-status.err{color:#e74c3c}
111
+ .vtv-inline-rec .rec-status.recording{color:#e74c3c;animation:vtv-rec-pulse 1s infinite}
112
+ .vtv-rec-panel{display:none;padding:10px;background:#0d0d20;border-top:1px solid #2a1a4a}
113
+ .vtv-rec-panel.show{display:block}
114
+ .vtv-rec-panel .rec-panel-title{font-size:11px;font-weight:700;color:#9b59b6;margin-bottom:8px}
115
+ .vtv-rec-panel .rec-preview-wrap{margin-bottom:8px;text-align:center}
116
+ .vtv-rec-panel .rec-preview-wrap video{max-width:100%;max-height:180px;border-radius:6px;background:#000}
117
+ .vtv-rec-panel .rec-ratio-row{display:flex;gap:4px;margin-bottom:8px}
118
+ .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}
119
+ .vtv-rec-panel .rec-ratio-row button.active{border-color:#9b59b6;color:#9b59b6;background:#2a1a4a}
120
+ .vtv-rec-panel .rec-actions{display:flex;gap:4px}
121
+ .vtv-rec-panel .rec-actions button{flex:1;padding:8px;border:none;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer}
122
+ .vtv-rec-panel .rec-actions .rec-download{background:#2d8659;color:#fff}
123
+ .vtv-rec-panel .rec-actions .rec-share{background:#9b59b6;color:#fff}
124
+ .vtv-rec-panel .rec-proc{text-align:center;padding:12px;color:#9b59b6;font-size:12px;display:none}
125
+ .vtv-rec-panel .rec-title-input{width:100%;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;padding:8px;color:#ccc;font-size:11px;margin-bottom:8px;box-sizing:border-box}
126
+ .vtv-rec-panel .rec-title-input:focus{border-color:#9b59b6;outline:none}
127
+ .vtv-rec-panel .rec-ai-title-row{display:flex;gap:4px;margin-bottom:8px}
128
+ .vtv-rec-panel .rec-ai-title-row button{flex:1;padding:6px;background:#1a1a2e;border:1px solid #2a2a4a;border-radius:6px;color:#888;font-size:10px;cursor:pointer}
129
+ .vtv-rec-panel .rec-ai-title-row button:hover{border-color:#9b59b6;color:#9b59b6}
130
+ .vtv-rec-panel .rec-ai-title-row button:disabled{opacity:.4;cursor:not-allowed}
131
+ .vtv-mini{position:fixed;top:0;left:0;right:0;z-index:99990;background:#000;border-bottom:2px solid #0066cc;box-shadow:0 4px 20px rgba(0,102,204,.4);display:none;transition:transform .3s ease}
132
+ .vtv-mini.show{display:block}
133
+ .vtv-mini.hidden{transform:translateY(-88%)}
134
+ .vtv-mini-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;max-height:180px}
135
+ .vtv-mini-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
136
+ .vtv-mini-bar{display:flex;align-items:center;justify-content:space-between;padding:3px 8px;background:linear-gradient(90deg,#003366,#1a1a1a)}
137
+ .vtv-mini-ch{font-size:10px;font-weight:700;color:#00ccff}
138
+ .vtv-mini-epg{font-size:9px;color:#8ab4d8;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:8px}
139
+ .vtv-mini-btns{display:flex;gap:3px}
140
+ .vtv-mini-btn{background:#1a2a3a;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 6px;border-radius:4px;cursor:pointer}
141
+ .vtv-mini-btn:hover{background:#0b4a7a;color:#fff}
142
+ .vtv-mini-btn.x{background:#600;border-color:#900;color:#f66}
143
+ .vtv-mini-btn.x:hover{background:#900;color:#fff}
144
+ .vtv-mini-peek{position:absolute;bottom:-18px;right:10px;background:#0066cc;color:#fff;font-size:9px;padding:2px 8px;border-radius:0 0 6px 6px;cursor:pointer;display:none}
145
+ .vtv-mini.hidden .vtv-mini-peek{display:block}
146
+ `;
147
+ document.head.appendChild(style);
148
 
149
+ // ===== HELPERS =====
150
+ function fmtSec(s){ return String(Math.floor(s/60)).padStart(2,'0')+':'+String(Math.floor(s%60)).padStart(2,'0'); }
151
+ function escH(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
152
+
153
+ function recStatus(msg, type) {
154
+ const el = document.getElementById('vtv-rec-status');
155
+ if (el) { el.textContent = msg; el.className = 'rec-status' + (type ? ' ' + type : ''); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  }
157
 
158
+ function downloadBlob(blob, filename) {
159
+ const a = document.createElement('a');
160
+ a.href = URL.createObjectURL(blob); a.download = filename; a.click();
161
+ URL.revokeObjectURL(a.href);
162
+ recStatus('✅ Đã tải: ' + filename, 'ok');
 
 
 
 
 
163
  }
164
 
165
+ function showProc(text) {
166
+ const el = document.getElementById('vtv-rec-proc');
167
+ if (el) { el.style.display = 'block'; document.getElementById('vtv-rec-proc-text').textContent = text; }
168
+ }
169
+ function hideProc() {
170
+ const el = document.getElementById('vtv-rec-proc');
171
+ if (el) el.style.display = 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
 
174
+ // ===== UPLOAD TO WALL (Short AI) — v7: robust JSON parse =====
175
+ function uploadToWall(blob, title, source) {
176
+ const formData = new FormData();
177
+ formData.append('video', blob, `vtv-${_currentCh}-${Date.now()}.webm`);
178
+ formData.append('title', title || `Record LIVE ${_currentCh?.toUpperCase()} - ${new Date().toLocaleString('vi-VN')}`);
179
+ formData.append('source', source || 'vtv_inline_recorder');
180
+ showProc('⏳ Đang upload lên Short AI...');
181
+ return fetch('/api/wall', { method: 'POST', body: formData })
182
+ .then(r => {
183
+ console.log('[VTV Upload] HTTP status:', r.status, r.statusText);
184
+ return r.text().then(text => {
185
+ console.log('[VTV Upload] Response text:', text.substring(0, 200));
186
+ let data;
187
+ try { data = JSON.parse(text); } catch(e) {
188
+ console.error('[VTV Upload] JSON parse failed:', e, 'Raw:', text.substring(0, 500));
189
+ throw new Error('Server trả về lỗi (không phải JSON). Status: ' + r.status);
190
+ }
191
+ return data;
192
+ });
193
+ })
194
+ .then(data => {
195
+ hideProc();
196
+ console.log('[VTV Upload] Parsed data:', data);
197
+ if (data && data.ok === true) {
198
+ recStatus('✅ Đã tạo Short AI! Xem trong mục Tường AI.', 'ok');
199
+ return data;
200
+ } else {
201
+ throw new Error(data && data.error ? data.error : 'Upload thất bại (ok=false)');
202
+ }
203
+ })
204
+ .catch(e => {
205
+ hideProc();
206
+ console.error('[VTV Upload] Error:', e);
207
+ recStatus('⚠️ ' + e.message + '. Đang tải về...', 'err');
208
+ setTimeout(() => downloadBlob(blob, `vtv-${_currentCh}-${Date.now()}.webm`), 800);
209
+ throw e;
210
+ });
211
  }
212
 
213
+ // ===== AI TITLE GENERATION =====
214
+ async function generateAITitle(blob, channelName) {
215
+ return new Promise((resolve) => {
216
+ const video = document.createElement('video');
217
+ video.src = URL.createObjectURL(blob);
218
+ video.muted = true;
219
+ video.onloadedmetadata = function() {
220
+ URL.revokeObjectURL(video.src);
221
+ const dur = Math.round(video.duration || 0);
222
+ const now = new Date();
223
+ const timeStr = now.toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'});
224
+ const dateStr = now.toLocaleDateString('vi-VN',{day:'2-digit',month:'2-digit'});
225
+ const titles = [
226
+ `🔴 LIVE ${channelName} - ${timeStr} ${dateStr}`,
227
+ `📺 ${channelName} - Video ngắn ${dur}s`,
228
+ `🎬 ${channelName} Record - ${timeStr}`,
229
+ `⚡ ${channelName} Highlights - ${dateStr}`,
230
+ `📱 ${channelName} Short - ${timeStr} ${dateStr}`,
231
+ ];
232
+ const idx = dur < 15 ? 4 : dur < 30 ? 1 : dur < 45 ? 2 : 0;
233
+ resolve(titles[idx % titles.length]);
234
+ };
235
+ video.onerror = function() {
236
+ URL.revokeObjectURL(video.src);
237
+ resolve(`📺 Record ${channelName} - ${new Date().toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'})}`);
238
+ };
 
239
  });
240
+ }
241
+
242
+ // ===== BUILD INLINE RECORDER =====
243
+ function buildInlineRecorder() {
244
+ const controls = document.querySelector('.vtv-controls');
245
+ if (!controls || document.getElementById('vtv-rec-btn')) return;
246
+
247
+ const recBtn = document.createElement('button');
248
+ recBtn.id = 'vtv-rec-btn';
249
+ recBtn.className = 'vtv-rec-btn';
250
+ recBtn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
251
+ recBtn.title = 'Record video ngắn từ LIVE (max 60s)';
252
+ recBtn.onclick = toggleRecPanel;
253
+ controls.appendChild(recBtn);
254
+
255
+ const inlineRec = document.createElement('div');
256
+ inlineRec.id = 'vtv-inline-rec';
257
+ inlineRec.className = 'vtv-inline-rec';
258
+ inlineRec.innerHTML = `
259
+ <div class="rec-time">
260
+ <span id="vtv-rec-time-start">00:00</span>
261
+ <span id="vtv-rec-time-dur">Duration: 0s</span>
262
+ <span id="vtv-rec-time-end">01:00</span>
263
+ </div>
264
+ <div class="rec-bar" id="vtv-rec-bar">
265
+ <div class="rec-progress" id="vtv-rec-progress"></div>
266
+ <div class="rec-marker s" id="vtv-rec-mk-start" style="display:none"></div>
267
+ <div class="rec-marker e" id="vtv-rec-mk-end" style="display:none"></div>
268
+ </div>
269
+ <div class="rec-controls">
270
+ <button class="rec-set-start" id="vtv-rec-set-start" onclick="window._vtvRecSetStart()">🔵 Điểm đầu</button>
271
+ <button class="rec-set-end" id="vtv-rec-set-end" onclick="window._vtvRecSetEnd()" disabled>🔴 Điểm cuối</button>
272
+ <button class="rec-go" id="vtv-rec-go" onclick="window._vtvRecGo()" disabled>⏺ Record!</button>
273
+ <button class="rec-reset" onclick="window._vtvRecReset()">↺ Reset</button>
274
+ </div>
275
+ <div class="rec-hint" id="vtv-rec-hint">Chọn điểm bắt đầu → kết thúc (tối đa 60s)</div>
276
+ <div class="rec-status" id="vtv-rec-status">Sẵn sàng</div>
277
+ `;
278
+ controls.parentNode.insertBefore(inlineRec, controls.nextSibling);
279
+
280
+ const recPanel = document.createElement('div');
281
+ recPanel.id = 'vtv-rec-panel';
282
+ recPanel.className = 'vtv-rec-panel';
283
+ recPanel.innerHTML = `
284
+ <div class="rec-panel-title">🎬 Đã record xong!</div>
285
+ <div class="rec-preview-wrap" id="vtv-rec-preview-wrap" style="display:none">
286
+ <video id="vtv-rec-preview-vid" controls style="max-width:100%;max-height:180px;border-radius:6px;background:#000"></video>
287
+ </div>
288
+ <input type="text" class="rec-title-input" id="vtv-rec-title-input" placeholder="Tiêu đề video (để trống = AI tự tạo)">
289
+ <div class="rec-ai-title-row">
290
+ <button onclick="window._vtvRecGenAITitle()" id="vtv-rec-ai-btn">🤖 AI tạo tiêu đề</button>
291
+ </div>
292
+ <div class="rec-ratio-row">
293
+ <button class="active" data-ratio="original" onclick="window._vtvRecSetRatio('original')">📺 Gốc</button>
294
+ <button data-ratio="1:1" onclick="window._vtvRecSetRatio('1:1')">⬜ 1:1</button>
295
+ <button data-ratio="9:16" onclick="window._vtvRecSetRatio('9:16')">📱 9:16</button>
296
+ </div>
297
+ <div class="rec-actions">
298
+ <button class="rec-download" onclick="window._vtvRecDownload()">💾 Lưu về máy</button>
299
+ <button class="rec-share" onclick="window._vtvRecShare()">🎬 Tạo Short AI</button>
300
+ </div>
301
+ <div class="rec-proc" id="vtv-rec-proc">
302
+ <div class="vtv-spinner" style="width:20px;height:20px;margin:0 auto 6px"></div>
303
+ <span id="vtv-rec-proc-text">Đang xử lý...</span>
304
+ </div>
305
+ `;
306
+ inlineRec.parentNode.insertBefore(recPanel, inlineRec.nextSibling);
307
+
308
+ document.getElementById('vtv-rec-bar').addEventListener('click', function(e) {
309
+ if (!_rec.isRecording) handleRecBarClick(e);
310
  });
311
+
312
+ document.getElementById('vtv-rec-mk-start').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; });
313
+ document.getElementById('vtv-rec-mk-start').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'start'; }, {passive:false});
314
+ document.getElementById('vtv-rec-mk-end').addEventListener('mousedown', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; });
315
+ document.getElementById('vtv-rec-mk-end').addEventListener('touchstart', function(e){ e.preventDefault(); e.stopPropagation(); _rec._dragMarker = 'end'; }, {passive:false});
316
  }
317
 
318
+ // Global drag
319
+ document.addEventListener('mousemove', function(e) { if (_rec._dragMarker) dragMarker(e.clientX); });
320
+ document.addEventListener('touchmove', function(e) { if (_rec._dragMarker) dragMarker(e.touches[0].clientX); }, {passive:false});
321
+ document.addEventListener('mouseup', function(){ _rec._dragMarker = null; });
322
+ document.addEventListener('touchend', function(){ _rec._dragMarker = null; });
323
 
324
+ function dragMarker(clientX) {
325
+ const bar = document.getElementById('vtv-rec-bar');
326
+ if (!bar) return;
327
+ const rect = bar.getBoundingClientRect();
328
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
329
+ const time = pct * 60;
330
+ if (_rec._dragMarker === 'start') {
331
+ if (_rec.endTime !== null && time >= _rec.endTime - 1) return;
332
+ _rec.startTime = time;
333
+ const mk = document.getElementById('vtv-rec-mk-start');
334
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
335
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
336
+ } else {
337
+ if (_rec.startTime !== null && time <= _rec.startTime + 1) return;
338
+ _rec.endTime = time;
339
+ const mk = document.getElementById('vtv-rec-mk-end');
340
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
341
+ document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
342
  }
343
+ updateRecBar();
 
 
 
 
 
344
  }
345
 
346
+ function handleRecBarClick(e) {
347
+ const video = document.getElementById('vtv-player');
348
+ if (!video || !video.src) { recStatus('⚠️ Chưa có stream!'); return; }
349
+ const bar = document.getElementById('vtv-rec-bar');
350
+ const rect = bar.getBoundingClientRect();
351
+ const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
352
+ const time = pct * 60;
353
+ if (_rec.startTime === null) {
354
+ _rec.startTime = time;
355
+ const mk = document.getElementById('vtv-rec-mk-start');
356
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
357
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(time);
358
+ document.getElementById('vtv-rec-set-end').disabled = false;
359
+ recStatus('Điểm đầu: ' + fmtSec(time) + '. Chọn điểm cuối.', '');
360
+ } else if (_rec.endTime === null) {
361
+ if (time <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
362
+ const dur = time - _rec.startTime;
363
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
364
+ _rec.endTime = time;
365
+ const mk = document.getElementById('vtv-rec-mk-end');
366
+ mk.style.display = 'block'; mk.style.left = (pct * 100) + '%';
367
+ document.getElementById('vtv-rec-time-end').textContent = fmtSec(time);
368
+ updateRecBar();
369
+ recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
370
+ document.getElementById('vtv-rec-go').disabled = false;
371
+ }
372
  }
373
 
374
+ function updateRecBar() {
375
+ const s = _rec.startTime || 0, e = _rec.endTime || 60;
376
+ const dur = e - s;
377
+ document.getElementById('vtv-rec-progress').style.left = (s/60*100) + '%';
378
+ document.getElementById('vtv-rec-progress').style.width = (dur/60*100) + '%';
379
+ document.getElementById('vtv-rec-time-dur').textContent = 'Duration: ' + Math.round(dur) + 's';
 
 
 
 
 
 
 
 
380
  }
381
 
382
+ // ===== RECORDER CONTROLS =====
383
+ window._vtvRecSetStart = function() {
384
+ const video = document.getElementById('vtv-player');
385
+ if (!video || !video.src) { recStatus('⚠️ Chưa có stream!', 'err'); return; }
386
+ _rec.startTime = video.currentTime || 0;
387
+ const pct = _rec.startTime / 60 * 100;
388
+ const mk = document.getElementById('vtv-rec-mk-start');
389
+ mk.style.display = 'block'; mk.style.left = pct + '%';
390
+ document.getElementById('vtv-rec-time-start').textContent = fmtSec(_rec.startTime);
391
+ document.getElementById('vtv-rec-set-end').disabled = false;
392
+ recStatus('Điểm đầu: ' + fmtSec(_rec.startTime), '');
393
+ };
394
 
395
+ window._vtvRecSetEnd = function() {
396
+ if (_rec.startTime === null) { recStatus('⚠️ Chọn điểm đầu trước!', 'err'); return; }
397
+ const video = document.getElementById('vtv-player');
398
+ if (!video) return;
399
+ const cur = video.currentTime || 0;
400
+ if (cur <= _rec.startTime) { recStatus('⚠️ Điểm cuối phải sau điểm đầu!', 'err'); return; }
401
+ const dur = cur - _rec.startTime;
402
+ if (dur > 60) { recStatus('⚠️ Tối đa 60s!', 'err'); return; }
403
+ _rec.endTime = cur;
404
+ const pct = cur / 60 * 100;
405
+ const mk = document.getElementById('vtv-rec-mk-end');
406
+ mk.style.display = 'block'; mk.style.left = pct + '%';
407
+ document.getElementById('vtv-rec-time-end').textContent = fmtSec(cur);
408
+ updateRecBar();
409
+ recStatus('✅ ' + Math.round(dur) + 's. Nhấn Record!', 'ok');
410
+ document.getElementById('vtv-rec-go').disabled = false;
411
+ };
412
+
413
+ window._vtvRecReset = function() {
414
+ _rec.startTime = null; _rec.endTime = null; _rec.isRecording = false; _rec.blob = null;
415
+ if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
416
+ clearTimeout(_rec._recTimer);
417
+ document.getElementById('vtv-rec-mk-start').style.display = 'none';
418
+ document.getElementById('vtv-rec-mk-end').style.display = 'none';
419
+ document.getElementById('vtv-rec-progress').style.width = '0%';
420
+ document.getElementById('vtv-rec-time-start').textContent = '00:00';
421
+ document.getElementById('vtv-rec-time-end').textContent = '01:00';
422
+ document.getElementById('vtv-rec-time-dur').textContent = 'Duration: 0s';
423
+ document.getElementById('vtv-rec-set-start').disabled = false;
424
+ document.getElementById('vtv-rec-set-end').disabled = true;
425
+ document.getElementById('vtv-rec-go').disabled = true;
426
+ document.getElementById('vtv-rec-panel').classList.remove('show');
427
+ document.getElementById('vtv-rec-title-input').value = '';
428
+ recStatus('Đã reset.', '');
429
+ const btn = document.getElementById('vtv-rec-btn');
430
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
431
+ };
432
+
433
+ window._vtvRecSetRatio = function(ratio) {
434
+ _rec.ratio = ratio;
435
+ document.querySelectorAll('#vtv-rec-panel .rec-ratio-row button').forEach(b => b.classList.toggle('active', b.dataset.ratio === ratio));
436
+ };
437
+
438
+ // ===== RECORD — captureStream with audio (unmute during record) =====
439
+ window._vtvRecGo = function() {
440
+ if (_rec.startTime === null || _rec.endTime === null) return;
441
+ if (_rec.isRecording) {
442
+ if (_rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
443
+ _rec.isRecording = false;
444
+ clearTimeout(_rec._recTimer);
445
+ const btn = document.getElementById('vtv-rec-btn');
446
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
447
+ return;
448
+ }
449
+
450
+ const video = document.getElementById('vtv-player');
451
+ if (!video) { recStatus('⚠️ Không tìm thấy video!', 'err'); return; }
452
+
453
+ _rec.chunks = [];
454
+ _rec.isRecording = true;
455
+
456
+ const wasMuted = video.muted;
457
+ video.muted = false;
458
+ const wasVolume = video.volume;
459
+ video.volume = 0.3;
460
+
461
+ let stream;
462
+ try {
463
+ stream = video.captureStream ? video.captureStream() : video.mozCaptureStream();
464
+ if (!stream) throw new Error('captureStream not supported');
465
+ } catch(e) {
466
+ video.muted = wasMuted; video.volume = wasVolume;
467
+ recStatus('❌ ' + e.message, 'err');
468
+ _rec.isRecording = false;
469
+ return;
470
  }
471
+
472
+ const audioTracks = stream.getAudioTracks();
473
+ console.log('[VTV Rec] Audio tracks:', audioTracks.length, 'Video tracks:', stream.getVideoTracks().length);
474
+
475
+ let mimeType = 'video/webm;codecs=vp9,opus';
476
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp8,opus';
477
+ if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
478
+
479
+ try { _rec.recorder = new MediaRecorder(stream, {mimeType}); }
480
+ catch(e) {
481
+ video.muted = wasMuted; video.volume = wasVolume;
482
+ recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err');
483
+ _rec.isRecording = false;
484
+ return;
485
+ }
486
+
487
+ _rec.recorder.ondataavailable = e => { if(e.data && e.data.size > 0) _rec.chunks.push(e.data); };
488
+ _rec.recorder.onstop = function() {
489
+ _rec.isRecording = false;
490
+ video.muted = wasMuted;
491
+ video.volume = wasVolume;
492
+ _rec.blob = new Blob(_rec.chunks, {type: 'video/webm'});
493
+ const btn = document.getElementById('vtv-rec-btn');
494
+ if (btn) { btn.classList.remove('recording'); btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record'; }
495
+ document.getElementById('vtv-rec-panel').classList.add('show');
496
+ const pv = document.getElementById('vtv-rec-preview-vid');
497
+ pv.src = URL.createObjectURL(_rec.blob);
498
+ document.getElementById('vtv-rec-preview-wrap').style.display = 'block';
499
+ const sizeMB = (_rec.blob.size/1024/1024).toFixed(1);
500
+ recStatus('✅ Record xong! ' + sizeMB + 'MB' + (audioTracks.length > 0 ? ' (có audio)' : ' (video only)'), 'ok');
501
+ };
502
+ _rec.recorder.onerror = e => {
503
+ _rec.isRecording = false;
504
+ video.muted = wasMuted; video.volume = wasVolume;
505
+ recStatus('❌ Lỗi: ' + (e.error?.message||'?'), 'err');
506
+ };
507
+
508
+ _rec.recorder.start(500);
509
+ video.currentTime = _rec.startTime;
510
+
511
+ const btn = document.getElementById('vtv-rec-btn');
512
+ 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'; }
513
+ recStatus('🔴 ĐANG RECORD...' + (audioTracks.length > 0 ? ' (có audio)' : ''), 'recording');
514
+
515
+ const dur = (_rec.endTime - _rec.startTime) * 1000;
516
+ _rec._recTimer = setTimeout(() => {
517
+ if (_rec.isRecording && _rec.recorder && _rec.recorder.state !== 'inactive') _rec.recorder.stop();
518
+ }, dur + 500);
519
+ };
520
+
521
+ // ===== AI TITLE =====
522
+ window._vtvRecGenAITitle = async function() {
523
+ if (!_rec.blob) { recStatus('⚠️ Chưa record!', 'err'); return; }
524
+ const btn = document.getElementById('vtv-rec-ai-btn');
525
+ const input = document.getElementById('vtv-rec-title-input');
526
+ if (!btn || !input) return;
527
+
528
+ btn.disabled = true;
529
+ btn.textContent = '⏳ Đang tạo...';
530
+
531
+ try {
532
+ const ch = CHANNELS.find(c => c.id === _currentCh);
533
+ const channelName = ch ? ch.name : (_currentCh || 'VTV');
534
+ const title = await generateAITitle(_rec.blob, channelName);
535
+ input.value = title;
536
+ recStatus('✅ Đã tạo tiêu đề AI: ' + title, 'ok');
537
+ } catch(e) {
538
+ recStatus('⚠️ Lỗi AI: ' + e.message, 'err');
539
+ } finally {
540
+ btn.disabled = false;
541
+ btn.textContent = '🤖 AI tạo tiêu đề';
542
+ }
543
+ };
544
+
545
+ // ===== DOWNLOAD =====
546
+ window._vtvRecDownload = function() {
547
+ if (!_rec.blob) return;
548
+ const title = document.getElementById('vtv-rec-title-input')?.value || '';
549
+ if (_rec.ratio === 'original') {
550
+ downloadBlob(_rec.blob, `vtv-${_currentCh}-${Date.now()}.webm`);
551
+ } else {
552
+ processRatio(_rec.blob, _rec.ratio, 'download', title);
553
+ }
554
+ };
555
+
556
+ // ===== SHARE TO SHORT AI =====
557
+ window._vtvRecShare = function() {
558
+ if (!_rec.blob) return;
559
+ const title = document.getElementById('vtv-rec-title-input')?.value || '';
560
+ if (_rec.ratio !== 'original') {
561
+ processRatio(_rec.blob, _rec.ratio, 'upload', title);
562
+ } else {
563
+ uploadToWall(_rec.blob, title);
564
+ }
565
+ };
566
+
567
+ // ===== RATIO CROP — v7: reliable render with timeout fallback =====
568
+ function processRatio(blob, ratio, action, title) {
569
+ showProc(`🔄 Đang xử lý ${ratio}...`);
570
+
571
+ const blobUrl = URL.createObjectURL(blob);
572
+ const video = document.createElement('video');
573
+ video.src = blobUrl;
574
+ video.volume = 0;
575
+ video.preload = 'auto';
576
+ video.playsInline = true;
577
+
578
+ function onReady() {
579
+ video.removeEventListener('loadedmetadata', onReady);
580
+ video.removeEventListener('canplay', onReady);
581
+
582
+ const vw = video.videoWidth, vh = video.videoHeight;
583
+ if (!vw || !vh) {
584
+ hideProc(); recStatus('❌ Video không có kích thước hợp lệ.', 'err');
585
+ URL.revokeObjectURL(blobUrl); return;
586
+ }
587
+
588
+ let cw, ch, sx, sy;
589
+ if (ratio === '1:1') {
590
+ const size = Math.min(vw, vh); cw = ch = size;
591
+ sx = Math.floor((vw - size) / 2); sy = Math.floor((vh - size) / 2);
592
+ } else if (ratio === '9:16') {
593
+ ch = vh; cw = Math.min(vw, Math.round(vh * 9 / 16));
594
+ sx = Math.floor((vw - cw) / 2); sy = 0;
595
+ } else { cw = vw; ch = vh; sx = sy = 0; }
596
+
597
+ showProc(`🔄 Render ${cw}x${ch}...`);
598
+
599
+ const canvas = document.createElement('canvas');
600
+ canvas.width = cw; canvas.height = ch;
601
+ const ctx = canvas.getContext('2d');
602
+ const canvasStream = canvas.captureStream(30);
603
+
604
+ let recordStream = canvasStream;
605
+ const audioVideo = document.createElement('video');
606
+ audioVideo.src = blobUrl;
607
+ audioVideo.muted = true;
608
+ audioVideo.volume = 0;
609
+ audioVideo.preload = 'auto';
610
+ audioVideo.playsInline = true;
611
+
612
+ audioVideo.onloadedmetadata = function() {
613
+ try {
614
+ const aStr = audioVideo.captureStream ? audioVideo.captureStream() : null;
615
+ if (aStr) {
616
+ const tracks = aStr.getAudioTracks();
617
+ if (tracks.length > 0) {
618
+ recordStream = new MediaStream([...canvasStream.getVideoTracks(), ...tracks]);
619
+ console.log('[VTV Crop] Audio merged:', tracks.length, 'tracks');
620
+ }
621
+ }
622
+ } catch(e) { console.warn('[VTV Crop] Audio merge failed:', e); }
623
+ };
624
+
625
+ const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
626
+ ? 'video/webm;codecs=vp9,opus'
627
+ : MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
628
+ ? 'video/webm;codecs=vp8,opus' : 'video/webm';
629
+
630
+ let rec;
631
+ try { rec = new MediaRecorder(recordStream, {mimeType: mime}); }
632
+ catch(e) { hideProc(); recStatus('❌ Lỗi MediaRecorder: ' + e.message, 'err'); URL.revokeObjectURL(blobUrl); return; }
633
+
634
+ const chunks = [];
635
+ rec.ondataavailable = e => { if(e.data && e.data.size > 0) chunks.push(e.data); };
636
+ rec.onstop = () => {
637
+ hideProc();
638
+ URL.revokeObjectURL(blobUrl);
639
+ if (chunks.length === 0) {
640
+ recStatus('❌ Render thất bại (không có data). Thử lại hoặc chọn "Gốc".', 'err');
641
+ return;
642
+ }
643
+ const out = new Blob(chunks, {type: 'video/webm'});
644
+ if (action === 'download') {
645
+ downloadBlob(out, `vtv-${_currentCh}-${ratio.replace(':','x')}-${Date.now()}.webm`);
646
+ } else {
647
+ uploadToWall(out, title);
648
  }
649
+ };
650
+ rec.onerror = e => { hideProc(); recStatus('❌ Lỗi render: ' + (e.error?.message || '?'), 'err'); URL.revokeObjectURL(blobUrl); };
651
+
652
+ rec.start(200);
653
+
654
+ audioVideo.play().catch(() => {});
655
+
656
+ video.play().then(() => {
657
+ let finished = false;
658
+ function finish() {
659
+ if (finished) return;
660
+ finished = true;
661
+ console.log('[VTV Crop] finish() called');
662
+ try { rec.requestData(); } catch(e) {}
663
+ setTimeout(() => {
664
+ if (rec.state !== 'inactive') rec.stop();
665
+ try { audioVideo.pause(); } catch(e) {}
666
+ }, 300);
667
+ }
668
+ video.onended = finish;
669
+ audioVideo.onended = finish;
670
+
671
+ const expectedDur = (video.duration || 0) * 1000;
672
+ if (expectedDur > 0 && isFinite(expectedDur)) {
673
+ setTimeout(() => {
674
+ if (!finished) {
675
+ console.warn('[VTV Crop] Timeout fallback after', expectedDur + 3000, 'ms');
676
+ finish();
677
+ }
678
+ }, expectedDur + 3000);
679
+ } else {
680
+ console.warn('[VTV Crop] Duration unknown, using 60s timeout');
681
+ setTimeout(() => {
682
+ if (!finished) {
683
+ console.warn('[VTV Crop] 60s timeout fallback');
684
+ finish();
685
+ }
686
+ }, 60000);
687
+ }
688
+
689
+ function draw() {
690
+ if (video.paused || video.ended) return;
691
+ ctx.drawImage(video, sx, sy, cw, ch, 0, 0, cw, ch);
692
+ requestAnimationFrame(draw);
693
+ }
694
+ draw();
695
+ }).catch(e => {
696
+ hideProc(); recStatus('❌ Lỗi phát video: ' + e.message, 'err');
697
+ URL.revokeObjectURL(blobUrl);
698
+ });
699
+ }
700
+
701
+ video.onerror = function() {
702
+ hideProc(); recStatus('❌ Không đọc được video.', 'err');
703
+ URL.revokeObjectURL(blobUrl);
704
+ };
705
+
706
+ video.addEventListener('loadedmetadata', onReady);
707
+ video.addEventListener('canplay', onReady);
708
+ }
709
+
710
+ // ===== TOGGLE REC PANEL =====
711
+ function toggleRecPanel() {
712
+ const panel = document.getElementById('vtv-inline-rec');
713
+ if (!panel) { buildInlineRecorder(); setTimeout(toggleRecPanel, 200); return; }
714
+ _rec.active = !_rec.active;
715
+ panel.classList.toggle('show', _rec.active);
716
+ const btn = document.getElementById('vtv-rec-btn');
717
+ if (btn) {
718
+ if (_rec.active) {
719
+ btn.style.background = '#3a1a1a';
720
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> Đóng';
721
+ } else {
722
+ btn.style.background = '';
723
+ btn.innerHTML = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/></svg> Record';
724
+ document.getElementById('vtv-rec-panel').classList.remove('show');
725
  }
726
+ }
727
+ }
728
+
729
+ // ===== PIN BUTTON =====
730
+ function setupPinButton(){
731
+ const block = document.getElementById('vtv-block');
732
+ if(!block) return;
733
+ const header = block.querySelector('.vtv-head');
734
+ if(!header || document.getElementById('vtv-pin-btn')) return;
735
+ const btn = document.createElement('button');
736
+ btn.id = 'vtv-pin-btn'; btn.className = 'vtv-pin-btn';
737
+ btn.innerHTML = '📌 Ghim';
738
+ btn.title = 'Ghim VTV cố định trên đầu trang';
739
+ btn.onclick = function(e){
740
+ e.stopPropagation(); _vtvPinned = !_vtvPinned;
741
+ if(_vtvPinned){ block.classList.add('vtv-sticky'); btn.classList.add('pinned'); btn.innerHTML = '📌 Đã ghim'; }
742
+ else { block.classList.remove('vtv-sticky'); btn.classList.remove('pinned'); btn.innerHTML = '📌 Ghim'; }
743
+ };
744
+ header.style.position = 'relative';
745
+ header.appendChild(btn);
746
+ }
747
+
748
+ // ===== MINI PLAYER =====
749
+ function createMiniPlayer(){
750
+ if(document.getElementById('vtv-mini')) return;
751
+ const mini = document.createElement('div');
752
+ mini.className = 'vtv-mini'; mini.id = 'vtv-mini';
753
+ mini.innerHTML = `<div class="vtv-mini-frame"><video id="vtv-mini-vid" playsinline muted preload="auto"></video></div>
754
+ <div class="vtv-mini-bar"><div style="display:flex;align-items:center;flex:1;min-width:0"><span class="vtv-mini-ch" id="vtv-mini-ch">VTV</span><span class="vtv-mini-epg" id="vtv-mini-epg"></span></div>
755
+ <div class="vtv-mini-btns"><button class="vtv-mini-btn" id="vtv-mini-pip">📺</button><button class="vtv-mini-btn" id="vtv-mini-expand">⬆️</button><button class="vtv-mini-btn" id="vtv-mini-hide">⬇️</button><button class="vtv-mini-btn x" id="vtv-mini-close">✕</button></div></div>
756
+ <div class="vtv-mini-peek" id="vtv-mini-peek">📺 Xem</div>`;
757
+ document.body.appendChild(mini);
758
+ document.getElementById('vtv-mini-pip').onclick = async function(){
759
+ const v = document.getElementById('vtv-mini-vid');
760
+ try{ if(document.pictureInPictureElement === v) await document.exitPictureInPicture(); else await v.requestPictureInPicture(); }catch(e){}
761
  };
762
+ document.getElementById('vtv-mini-expand').onclick = function(){ closeMiniPlayer(); if(typeof switchCat==='function') switchCat('home'); };
763
+ document.getElementById('vtv-mini-hide').onclick = ()=>{ mini.classList.add('hidden'); };
764
+ document.getElementById('vtv-mini-close').onclick = closeMiniPlayer;
765
+ document.getElementById('vtv-mini-peek').onclick = ()=>{ mini.classList.remove('hidden'); };
766
+ }
767
+ function closeMiniPlayer(){ const m=document.getElementById('vtv-mini'); if(m){const v=document.getElementById('vtv-mini-vid'); if(v){v.pause();v.src='';} m.remove();} _miniActive=false; }
768
+ function activateMiniPlayer(){
769
+ if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
770
+ const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
771
+ if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:4,fragLoadingTimeOut:8000,manifestLoadingTimeOut:8000}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
772
+ else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
773
+ const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
774
+ fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[]; const n=p.find(x=>x.now); document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});
775
+ mini.classList.add('show'); mini.classList.remove('hidden');
776
+ }
777
+
778
+ // ===== MAIN PLAYER =====
779
+ async function loadAllStreams(){
780
+ if(_loading) return; _loading=true;
781
+ const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
782
+ try{const r=await fetch('/api/vtv/streams'); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u=i.stream_url; if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u); STREAMS[ch.id]=[u];} else STREAMS[ch.id]=[];});}}catch(e){}
783
+ CHANNELS.forEach(ch=>{const t=document.getElementById('vtvt-'+ch.id); if(t){if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off'); t.textContent=ch.name;}else{t.style.opacity='0.35'; t.textContent=ch.name+' ✕';}}});
784
+ _loading=false; _streamsLoaded=true;
785
  }
786
 
787
+ function buildBlock(){
788
+ const w=document.createElement('div'); w.className='vtv-wrap'; w.id='vtv-block';
789
+ let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
790
+ w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
791
+ '<div class="vtv-tabs">'+tabs+'</div>'+
792
+ '<div class="vtv-frame"><div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải kênh...</div><video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video><div class="vtv-err" id="vtv-err" style="display:none"><span id="vtv-err-msg">Không thể tải kênh</span><button onclick="window._vtvRetry()">Thử lại</button></div></div>'+
793
+ '<div class="vtv-controls"><button class="vtv-pip-btn" id="vtv-pip-btn" onclick="window._vtvTogglePiP()"><svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="12" y="10" width="8" height="6" rx="1" fill="currentColor"/></svg>PiP</button><span style="flex:1"></span><span style="font-size:9px;color:#666" id="vtv-status"></span></div>'+
794
+ '<div class="vtv-epg" id="vtv-epg"><div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span><button class="vtv-epg-toggle" id="vtv-epg-toggle" onclick="window._vtvToggleEpg()">Ẩn</button></div><div class="vtv-epg-list" id="vtv-epg-list"><div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải...</div></div></div>';
795
+ return w;
796
  }
797
 
798
+ async function loadEpg(chId){
799
+ const el=document.getElementById('vtv-epg-list'); if(!el) return;
800
+ el.innerHTML='<div class="vtv-epg-loading"><div class="vtv-epg-sp"></div>Đang tải lịch...</div>';
801
+ try{const r=await fetch('/api/vtv/epg/'+chId); if(!r.ok) throw new Error(); const d=await r.json(); const p=d.programs||[]; if(!p.length){el.innerHTML='<div class="vtv-epg-empty">Chưa có lịch</div>';return;} let h=''; p.forEach(x=>{const n=x.now?' now':''; h+='<div class="vtv-epg-item'+n+'"><span class="t">'+(x.time||'')+(x.end_time?'-'+x.end_time:'')+'</span> <span class="n">'+(x.title||'')+'</span></div>';}); el.innerHTML=h; const ni=el.querySelector('.vtv-epg-item.now'); if(ni) ni.scrollIntoView({behavior:'smooth',inline:'center',block:'nearest'});}catch(e){el.innerHTML='<div class="vtv-epg-empty">Không tải được lịch</div>';}
802
  }
803
 
804
+ window._vtvToggleEpg=function(){const l=document.getElementById('vtv-epg-list'),b=document.getElementById('vtv-epg-toggle'); if(!l||!b)return; if(l.style.display==='none'){l.style.display='flex';b.textContent='Ẩn';}else{l.style.display='none';b.textContent='Hiện';}};
805
+ window._vtvTogglePiP=async function(){const v=document.getElementById('vtv-player'); const b=document.getElementById('vtv-pip-btn'); if(!v)return; try{if(document.pictureInPictureElement===v){await document.exitPictureInPicture();_pipActive=false;if(b)b.classList.remove('on');}else{await v.requestPictureInPicture();_pipActive=true;if(b)b.classList.add('on');}}catch(e){}};
806
+
807
+ function pinBlock(){
808
+ const h=document.getElementById('view-home'); if(!h||document.getElementById('vtv-block')) return;
809
+ h.insertBefore(buildBlock(),h.firstChild); _blockInserted=true;
810
+ setTimeout(()=>{setupPinButton();buildInlineRecorder();},500);
811
+ if(!_streamsLoaded){loadAllStreams().then(()=>{setTimeout(()=>window._vtvPlay(DEFAULT_CHANNEL),300);});}
812
+ }
813
+
814
+ window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
815
+
816
+ window._vtvPlay=function(chId){
817
+ const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
818
+ document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
819
+ const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
820
+ const video=document.getElementById('vtv-player'); const errEl=document.getElementById('vtv-err'); const loadEl=document.getElementById('vtv-load'); const errMsg=document.getElementById('vtv-err-msg');
821
+ video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
822
+ loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
823
  if(_hls){_hls.destroy();_hls=null;}
 
 
 
 
824
  const urls=STREAMS[chId]||[];
825
+ if(!urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=chId==='vtvprime'?'VTVPrime: Kênh trả phí.':ch.name+': Không có luồng.';return;}
826
  _tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
827
  loadEpg(chId);
828
+ if(_miniActive){document.getElementById('vtv-mini-ch').textContent=ch.name;fetch('/api/vtv/epg/'+_currentCh).then(r=>r.json()).then(d=>{const p=d.programs||[];const n=p.find(x=>x.now);document.getElementById('vtv-mini-epg').textContent=n?n.time+' '+n.title:(p[0]?p[0].time+' '+p[0].title:'');}).catch(()=>{});}
829
+ };
 
 
 
 
 
 
830
 
831
  function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
832
+ if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
833
+ const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
 
834
  if(typeof Hls!=='undefined'&&Hls.isSupported()){
835
+ const hls=new Hls({enableWorker:true,lowLatencyMode:false,startLevel:0,capLevelToPlayerSize:true,maxBufferLength:6,maxMaxBufferLength:10,liveSyncDurationCount:2,liveMaxLatencyDurationCount:3,fragLoadingTimeOut:10000,manifestLoadingTimeOut:10000}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
836
+ hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
 
 
 
 
 
 
 
 
 
 
 
 
837
  let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{if(data.fatal){if(data.type===Hls.ErrorTypes.NETWORK_ERROR){rec++;if(rec<=3)setTimeout(()=>hls.startLoad(),2000);else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){try{hls.recoverMediaError();}catch(e){}}else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}}});
838
  }else if(video.canPlayType('application/vnd.apple.mpegurl')){
839
  video.src=src;
840
  video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
841
  video.addEventListener('error',()=>{_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);},{once:true});
842
+ }else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
843
  }
844
 
845
+ // ===== NAV HOOKS =====
846
  const _origShowView=window.showView;
847
  window.showView=function(id){if(id==='view-home'&&_miniActive)closeMiniPlayer();else if(id!=='view-home'&&_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origShowView.apply(this,arguments);};
848
  const _origReadArticle=window.readArticle; if(_origReadArticle){window.readArticle=function(url){if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origReadArticle.apply(this,arguments);};}
 
853
  window.loadHome=async function(){const old=document.getElementById('vtv-block');if(old)old.remove();_blockInserted=false;const r=await _origLoadHome.apply(this,arguments);try{pinBlock();}catch(e){}return r;};
854
  window.loadHome.__vtvWrapped=true;
855
  }
 
 
 
 
 
 
 
 
856
  })();