bep40 commited on
Commit
6913c45
·
verified ·
1 Parent(s): 592c71e

TV player JS - fresh

Browse files
Files changed (1) hide show
  1. static/tv_player.js +348 -0
static/tv_player.js ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // === VNEWS — VTV1-VTV10 + VTVPrime LIVE CHANNELS + EPG ===
2
+ // Uses backend /api/vtv/streams for stream URLs
3
+ // Default channel: VTV6 | No double-load | EPG schedule
4
+
5
+ (function(){
6
+ if(window._ytLiveLoaded) return;
7
+ window._ytLiveLoaded = true;
8
+
9
+ const CHANNELS = [
10
+ {id:'vtv1', name:'VTV1', badge:'Tin tức'},
11
+ {id:'vtv2', name:'VTV2', badge:'Khoa học'},
12
+ {id:'vtv3', name:'VTV3', badge:'Giải trí'},
13
+ {id:'vtv4', name:'VTV4', badge:'Quốc tế'},
14
+ {id:'vtv5', name:'VTV5', badge:'Miền Nam'},
15
+ {id:'vtv6', name:'VTV6', badge:'Thanh niên'},
16
+ {id:'vtv7', name:'VTV7', badge:'Giáo dục'},
17
+ {id:'vtv8', name:'VTV8', badge:'Miền Trung'},
18
+ {id:'vtv9', name:'VTV9', badge:'Miền Bắc'},
19
+ {id:'vtv10', name:'VTV10', badge:'VTV10'},
20
+ {id:'vtvprime', name:'VTVPrime', badge:'Prime'},
21
+ ];
22
+
23
+ // ===== EPG — Lịch phát sóng mẫu cho từng kênh =====
24
+ const EPG = {
25
+ vtv1: [
26
+ {t:'06:00',n:'Nhật ký ngày mai'},{t:'07:00',n:'Thời sự sáng'},{t:'09:00',n:'Thời sự'},
27
+ {t:'12:00',n:'Thời sự trưa'},{t:'15:00',n:'Thời sự chiều'},{t:'19:00',n:'Thời sự tối'},
28
+ {t:'21:00',n:'Thời sự đêm'},{t:'23:00',n:'Nhật ký ngày mai'},
29
+ ],
30
+ vtv2: [
31
+ {t:'06:00',n:'Khoa học & Công nghệ'},{t:'08:00',n:'Thế giới tự nhiên'},{t:'10:00',n:'Khoa học 360'},
32
+ {t:'12:00',n:'Đi tìm giải pháp'},{t:'14:00',n:'Sức khỏe & Cuộc sống'},{t:'16:00',n:'Khoa học cho mọi nhà'},
33
+ {t:'18:00',n:'Thế giới động vật'},{t:'20:00',n:'Khoa học & Tương lai'},{t:'22:00',n:'Tài liệu khoa học'},
34
+ ],
35
+ vtv3: [
36
+ {t:'06:00',n:'Sáng vui'},{t:'08:00',n:'Phim truyện'},{t:'10:00',n:'Gameshow'},
37
+ {t:'12:00',n:'Âm nhạc'},{t:'14:00',n:'Phim truyện'},{t:'16:00',n:'Giải trí chiều'},
38
+ {t:'18:00',n:'Tạp kỹ thuật số'},{t:'20:00',n:'Phim truyện đặc biệt'},{t:'22:00',n:'Đêm giải trí'},
39
+ ],
40
+ vtv4: [
41
+ {t:'06:00',n:'News'},{t:'08:00',n:'World News'},{t:'10:00',n:'Culture'},
42
+ {t:'12:00',n:'Midday News'},{t:'14:00',n:'Documentary'},{t:'16:00',n:'Sports'},
43
+ {t:'18:00',n:'Evening News'},{t:'20:00',n:'World Today'},{t:'22:00',n:'Nightline'},
44
+ ],
45
+ vtv5: [
46
+ {t:'06:00',n:'Thời sự miền Nam'},{t:'08:00',n:'Chương trình thiếu nhi'},{t:'10:00',n:'Phim truyện'},
47
+ {t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao miền Nam'},
48
+ {t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'},
49
+ ],
50
+ vtv6: [
51
+ {t:'06:00',n:'Khởi động ngày mới'},{t:'08:00',n:'Thanh niên & Sáng tạo'},{t:'10:00',n:'Thế giới trẻ'},
52
+ {t:'12:00',n:'Nhịp sống trẻ'},{t:'14:00',n:'Thể thao tuổi trẻ'},{t:'16:00',n:'Giải trí thanh niên'},
53
+ {t:'18:00',n:'Thời sự trẻ'},{t:'20:00',n:'Đêm nhạc'},{t:'22:00',n:'Thanh niên & Đêm'},
54
+ ],
55
+ vtv7: [
56
+ {t:'06:00',n:'Giáo dục sáng'},{t:'08:00',n:'Học mọi lúc'},{t:'10:00',n:'Kỹ năng sống'},
57
+ {t:'12:00',n:'Giáo dục trưa'},{t:'14:00',n:'Học trực tuyến'},{t:'16:00',n:'Thiếu nhi'},
58
+ {t:'18:00',n:'Giáo dục chiều'},{t:'20:00',n:'Tài liệu giáo dục'},{t:'22:00',n:'Học suốt đời'},
59
+ ],
60
+ vtv8: [
61
+ {t:'06:00',n:'Thời sự miền Trung'},{t:'08:00',n:'Văn hóa miền Trung'},{t:'10:00',n:'Phim truyện'},
62
+ {t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao miền Trung'},
63
+ {t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'},
64
+ ],
65
+ vtv9: [
66
+ {t:'06:00',n:'Thời sự miền Bắc'},{t:'08:00',n:'Văn hóa miền Bắc'},{t:'10:00',n:'Phim truyện'},
67
+ {t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao miền Bắc'},
68
+ {t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'},
69
+ ],
70
+ vtv10: [
71
+ {t:'06:00',n:'Thời sự Tây Nam Bộ'},{t:'08:00',n:'Văn hóa đồng bằng'},{t:'10:00',n:'Phim truyện'},
72
+ {t:'12:00',n:'Thời sự trưa'},{t:'14:00',n:'Giải trí'},{t:'16:00',n:'Thể thao Tây Nam Bộ'},
73
+ {t:'18:00',n:'Thời sự chiều'},{t:'20:00',n:'Phim truyện'},{t:'22:00',n:'Thời sự tối'},
74
+ ],
75
+ vtvprime: [
76
+ {t:'06:00',n:'Prime Morning'},{t:'08:00',n:'Prime Cinema'},{t:'10:00',n:'Prime Sports'},
77
+ {t:'12:00',n:'Prime News'},{t:'14:00',n:'Prime Drama'},{t:'16:00',n:'Prime Entertainment'},
78
+ {t:'18:00',n:'Prime Evening'},{t:'20:00',n:'Prime Night'},{t:'22:00',n:'Prime Late'},
79
+ ],
80
+ };
81
+
82
+ // ALL external streams need proxy — VTVGo/fptplay CDNs don't send CORS headers
83
+ const STREAMS = {};
84
+ let _currentCh = null;
85
+ let _hls = null;
86
+ let _loading = false;
87
+ let _epgVisible = false;
88
+
89
+ const s = document.createElement('style');
90
+ s.textContent = `
91
+ .vtv-wrap{margin:6px 4px;background:#111;border:1px solid #0066cc;border-radius:10px;overflow:hidden}
92
+ .vtv-head{display:flex;align-items:center;gap:8px;padding:8px 10px;background:linear-gradient(90deg,#003366,#1a1a1a)}
93
+ .vtv-title{font-size:13px;font-weight:800;color:#00ccff}
94
+ .vtv-badge{font-size:10px;font-weight:800;color:#00ccff;animation:vtvp 1.3s infinite}
95
+ @keyframes vtvp{0%,100%{opacity:1}50%{opacity:.3}}
96
+ .vtv-tabs{display:flex;gap:3px;padding:6px 8px;overflow-x:auto;scrollbar-width:none;background:#0d1a2a}
97
+ .vtv-tabs::-webkit-scrollbar{display:none}
98
+ .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}
99
+ .vtv-tab:hover{background:#0b4a7a;color:#fff}
100
+ .vtv-tab.on{background:#0066cc;border-color:#00ccff;color:#fff;font-weight:700}
101
+ .vtv-tab.off{opacity:.35;pointer-events:none}
102
+ .vtv-frame{position:relative;width:100%;aspect-ratio:16/9;background:#000;min-height:180px}
103
+ .vtv-frame video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
104
+ .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}
105
+ .vtv-err button{background:#0066cc;border:none;color:#fff;padding:6px 14px;border-radius:8px;font-size:11px;cursor:pointer}
106
+ .vtv-load{display:flex;align-items:center;justify-content:center;height:180px;color:#00ccff;font-size:12px;flex-direction:column;gap:8px}
107
+ .vtv-spinner{width:24px;height:24px;border:2px solid #333;border-top-color:#00ccff;border-radius:50%;animation:vtvspin .8s linear infinite}
108
+ @keyframes vtvspin{to{transform:rotate(360deg)}}
109
+ .vtv-epg{margin:0;padding:6px 10px;background:#0a1628;border-top:1px solid #1a2a3a}
110
+ .vtv-epg-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
111
+ .vtv-epg-title{font-size:10px;font-weight:700;color:#00ccff}
112
+ .vtv-epg-toggle{background:none;border:1px solid #2a3a4a;color:#8ab4d8;font-size:9px;padding:2px 8px;border-radius:6px;cursor:pointer}
113
+ .vtv-epg-list{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none;padding-bottom:4px}
114
+ .vtv-epg-list::-webkit-scrollbar{display:none}
115
+ .vtv-epg-item{flex:0 0 auto;padding:3px 6px;background:#1a2a3a;border-radius:4px;font-size:8px;color:#8ab4d8;white-space:nowrap}
116
+ .vtv-epg-item.now{background:#0066cc;color:#fff;font-weight:700}
117
+ .vtv-epg-item .epg-t{font-size:7px;color:#6a8aaa}
118
+ .vtv-epg-item.now .epg-t{color:#aaccee}
119
+ .vtv-epg-item .epg-n{color:#ccc;font-size:8px}
120
+ .vtv-epg-item.now .epg-n{color:#fff}
121
+ `;
122
+ document.head.appendChild(s);
123
+
124
+ function getCurrentHour(){
125
+ return new Date().getHours();
126
+ }
127
+
128
+ function buildEPGHTML(chId){
129
+ const epg = EPG[chId] || [];
130
+ if(!epg.length) return '';
131
+ const curH = getCurrentHour();
132
+ let items = '';
133
+ epg.forEach(item => {
134
+ const itemH = parseInt(item.t.split(':')[0], 10);
135
+ const isNow = itemH <= curH && (itemH + 2) > curH;
136
+ items += `<div class="vtv-epg-item${isNow?' now':''}"><div class="epg-t">${item.t}</div><div class="epg-n">${item.n}</div></div>`;
137
+ });
138
+ return `<div class="vtv-epg" id="vtv-epg">' +
139
+ '<div class="vtv-epg-header"><span class="vtv-epg-title">📋 Lịch phát sóng</span>' +
140
+ '<button class="vtv-epg-toggle" onclick="window._vtvToggleEPG()">Ẩn/Hiện</button></div>' +
141
+ '<div class="vtv-epg-list" id="vtv-epg-list">' + items + '</div></div>';
142
+ }
143
+
144
+ window._vtvToggleEPG = function(){
145
+ const list = document.getElementById('vtv-epg-list');
146
+ if(list) list.style.display = list.style.display === 'none' ? 'flex' : 'none';
147
+ };
148
+
149
+ async function loadAllStreams(){
150
+ if(_loading) return;
151
+ _loading = true;
152
+ const loadEl = document.getElementById('vtv-load');
153
+ if(loadEl) loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang tải danh sách kênh...';
154
+
155
+ try {
156
+ const r = await fetch('/api/vtv/streams', {signal: AbortSignal.timeout(10000)});
157
+ if(r.ok){
158
+ const data = await r.json();
159
+ CHANNELS.forEach(ch => {
160
+ const info = data[ch.id];
161
+ if(info && info.stream_url){
162
+ // Always proxy through backend to avoid CORS issues
163
+ const url = '/api/proxy/m3u8/vtv?url=' + encodeURIComponent(info.stream_url);
164
+ STREAMS[ch.id] = [url];
165
+ } else {
166
+ STREAMS[ch.id] = [];
167
+ }
168
+ });
169
+ }
170
+ } catch(e) {
171
+ console.warn('VTV API error:', e);
172
+ }
173
+
174
+ CHANNELS.forEach(ch => {
175
+ const tab = document.getElementById('vtvt-'+ch.id);
176
+ if(tab){
177
+ if(STREAMS[ch.id] && STREAMS[ch.id].length > 0){
178
+ tab.classList.remove('off');
179
+ tab.textContent = ch.name;
180
+ } else {
181
+ tab.style.opacity = '0.35';
182
+ tab.textContent = ch.name + ' ✕';
183
+ }
184
+ }
185
+ });
186
+ _loading = false;
187
+ }
188
+
189
+ function buildBlock(){
190
+ const w = document.createElement('div');
191
+ w.className = 'vtv-wrap';
192
+ w.id = 'vtv-block';
193
+ let tabs = '';
194
+ CHANNELS.forEach(ch => {
195
+ tabs += '<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';
196
+ });
197
+ w.innerHTML =
198
+ '<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>' +
199
+ '<div class="vtv-tabs">' + tabs + '</div>' +
200
+ '<div class="vtv-frame">' +
201
+ '<div class="vtv-load" id="vtv-load"><div class="vtv-spinner"></div>Đang tải danh sách kênh...</div>' +
202
+ '<video id="vtv-player" playsinline muted controls preload="auto" style="display:none"></video>' +
203
+ '<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>' +
204
+ '</div>';
205
+ return w;
206
+ }
207
+
208
+ // ===== PIN BLOCK — called only once via loadHome wrapper =====
209
+ function pinBlock(){
210
+ const h = document.getElementById('view-home');
211
+ if(!h || document.getElementById('vtv-block')) return;
212
+ h.insertBefore(buildBlock(), h.firstChild);
213
+ loadAllStreams().then(() => {
214
+ // Default to VTV6 if available, otherwise first available channel
215
+ const tryOrder = ['vtv6','vtv1','vtv2','vtv3','vtv4','vtv5','vtv7','vtv8','vtv9','vtv10'];
216
+ for(const chId of tryOrder){
217
+ if(STREAMS[chId] && STREAMS[chId].length > 0){
218
+ setTimeout(() => window._vtvPlay(chId), 300);
219
+ return;
220
+ }
221
+ }
222
+ });
223
+ }
224
+
225
+ window._vtvRetry = function(){
226
+ if(_currentCh) window._vtvPlay(_currentCh);
227
+ };
228
+
229
+ window._vtvPlay = function(chId){
230
+ const ch = CHANNELS.find(c => c.id === chId);
231
+ if(!ch) return;
232
+ _currentCh = chId;
233
+ document.querySelectorAll('.vtv-tab').forEach(t => t.classList.remove('on'));
234
+ const tab = document.getElementById('vtvt-'+chId);
235
+ if(tab) tab.classList.add('on');
236
+ const video = document.getElementById('vtv-player');
237
+ const errEl = document.getElementById('vtv-err');
238
+ const loadEl = document.getElementById('vtv-load');
239
+ const errMsg = document.getElementById('vtv-err-msg');
240
+ video.style.display = 'none';
241
+ errEl.style.display = 'none';
242
+ loadEl.style.display = 'flex';
243
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + ch.name + '...';
244
+ if(_hls){ _hls.destroy(); _hls = null; }
245
+ const urls = STREAMS[chId] || [];
246
+ if(urls.length === 0){
247
+ loadEl.style.display = 'none';
248
+ errEl.style.display = 'flex';
249
+ if(chId === 'vtvprime'){
250
+ errMsg.textContent = 'VTVPrime: Kênh trả phí, không có luồng miễn phí.';
251
+ } else {
252
+ errMsg.textContent = ch.name + ': Không tìm thấy luồng. Thử lại sau.';
253
+ }
254
+ return;
255
+ }
256
+ // Update EPG
257
+ const epgEl = document.getElementById('vtv-epg');
258
+ if(epgEl) epgEl.remove();
259
+ const frame = document.querySelector('.vtv-frame');
260
+ if(frame){
261
+ const epgDiv = document.createElement('div');
262
+ epgDiv.innerHTML = buildEPGHTML(chId);
263
+ frame.appendChild(epgDiv.firstElementChild);
264
+ }
265
+ _tryPlay(video, urls, 0, ch.name, loadEl, errEl, errMsg);
266
+ };
267
+
268
+ function _tryPlay(video, urls, idx, name, loadEl, errEl, errMsg){
269
+ if(idx >= urls.length){
270
+ loadEl.style.display = 'none';
271
+ errEl.style.display = 'flex';
272
+ errMsg.textContent = name + ': Tất cả nguồn đều lỗi. Thử lại sau.';
273
+ return;
274
+ }
275
+ const src = urls[idx];
276
+ const sourceLabel = ' (' + (idx+1) + '/' + urls.length + ')';
277
+ loadEl.innerHTML = '<div class="vtv-spinner"></div>Đang kết nối ' + name + sourceLabel + '...';
278
+ if(typeof Hls !== 'undefined' && Hls.isSupported()){
279
+ const hls = new Hls({
280
+ enableWorker: true,
281
+ lowLatencyMode: true,
282
+ startLevel: -1,
283
+ capLevelToPlayerSize: true,
284
+ maxBufferLength: 20,
285
+ xhrSetup: function(xhr, url){
286
+ if(url.includes('fptplay')){
287
+ xhr.setRequestHeader('Referer', 'https://fptplay.vn/');
288
+ xhr.setRequestHeader('Origin', 'https://fptplay.vn');
289
+ }
290
+ }
291
+ });
292
+ _hls = hls;
293
+ hls.loadSource(src);
294
+ hls.attachMedia(video);
295
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
296
+ video.play().catch(() => {});
297
+ loadEl.style.display = 'none';
298
+ video.style.display = 'block';
299
+ });
300
+ let recoverAttempts = 0;
301
+ hls.on(Hls.Events.ERROR, (ev, data) => {
302
+ if(data.fatal){
303
+ if(data.type === Hls.ErrorTypes.NETWORK_ERROR){
304
+ recoverAttempts++;
305
+ if(recoverAttempts <= 3){
306
+ setTimeout(() => hls.startLoad(), 2000);
307
+ } else {
308
+ hls.destroy();
309
+ _hls = null;
310
+ _tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
311
+ }
312
+ } else if(data.type === Hls.ErrorTypes.MEDIA_ERROR){
313
+ try { hls.recoverMediaError(); } catch(e) {}
314
+ } else {
315
+ hls.destroy();
316
+ _hls = null;
317
+ _tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
318
+ }
319
+ }
320
+ });
321
+ } else if(video.canPlayType('application/vnd.apple.mpegurl')){
322
+ video.src = src;
323
+ video.addEventListener('loadedmetadata', () => {
324
+ video.play().catch(() => {});
325
+ loadEl.style.display = 'none';
326
+ video.style.display = 'block';
327
+ }, {once: true});
328
+ video.addEventListener('error', () => {
329
+ _tryPlay(video, urls, idx + 1, name, loadEl, errEl, errMsg);
330
+ }, {once: true});
331
+ } else {
332
+ loadEl.style.display = 'none';
333
+ errEl.style.display = 'flex';
334
+ errMsg.textContent = 'Trình duyệt không hỗ trợ HLS';
335
+ }
336
+ }
337
+
338
+ // ===== ONLY wrap loadHome — no DOMContentLoaded listener to avoid double-load =====
339
+ const orig = window.loadHome;
340
+ if(orig && !orig.__vtvWrapped){
341
+ window.loadHome = async function(){
342
+ const r = await orig.apply(this, arguments);
343
+ try{ pinBlock(); }catch(e){}
344
+ return r;
345
+ };
346
+ window.loadHome.__vtvWrapped = true;
347
+ }
348
+ })();