Spaces:
Running
Restore main to commit 0cc342c (#17)
Browse files- Upload main.py (086fe3ad0e052b0e0ded65187c9533bbd244197a)
- Restore README.md from 0cc342c (5c187791c671b742e48ec45d546e40dec5dced33)
- Restore app_v2_entry.py from 0cc342c (48dd5bcc1a710520531c708ce3c333a9e6799b80)
- Restore main.py from 0cc342c (ed786d76259fc9d08bafe72d472e08e63d74effa)
- Restore static/app_v2.js from 0cc342c (ce16b6052f38c50a4d32bd6ed8788f2d66fb3357)
- Restore static/index_v2.html from 0cc342c (e9642a30a475801db0302798a9366ee2dcc37883)
- Restore static/wc_shorts_inject.js from 0cc342c (90e304f837cb3b149d906eac95b089870a14cb33)
- Restore static/wc_shorts_slide.js from 0cc342c (fadc98259762ee58607328ffbc6fb5acc851378e)
- Restore static/yt_live.js from 0cc342c (1b5364650afc6a29b0ce5941f6a16dcdbadb6807)
- Restore vtv_api.py from 0cc342c (14679a6bf84446832cbe53ac8d74bad689c88ab2)
- Restore yt_scraper.py from 0cc342c (4ca19c39190a4c64f279cc348b55daef705544b3)
- README.md +1 -1
- app_v2_entry.py +1 -5
- static/app_v2.js +18 -18
- static/index_v2.html +4 -1
- static/wc_shorts_inject.js +76 -1
- static/wc_shorts_slide.js +597 -1
- static/yt_live.js +17 -100
- vtv_api.py +112 -288
- yt_scraper.py +1 -1
|
@@ -1,9 +1,9 @@
|
|
| 1 |
---
|
| 2 |
-
sdk: docker
|
| 3 |
title: VNEWS
|
| 4 |
emoji: 📰
|
| 5 |
colorFrom: green
|
| 6 |
colorTo: yellow
|
|
|
|
| 7 |
pinned: false
|
| 8 |
tags:
|
| 9 |
- ml-intern
|
|
|
|
| 1 |
---
|
|
|
|
| 2 |
title: VNEWS
|
| 3 |
emoji: 📰
|
| 4 |
colorFrom: green
|
| 5 |
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
tags:
|
| 9 |
- ml-intern
|
|
@@ -350,11 +350,7 @@ def _scrape_generic(url):
|
|
| 350 |
|
| 351 |
@app.get('/api/article')
|
| 352 |
def api_article_v2(url:str=Query(...)):
|
| 353 |
-
from main import
|
| 354 |
-
# Stubs for missing functions
|
| 355 |
-
def scrape_bbc_article(url): return scrape_vne_article(url)
|
| 356 |
-
def scrape_dantri_article(url): return scrape_vne_article(url)
|
| 357 |
-
def scrape_ttvh_article(url): return scrape_vne_article(url)
|
| 358 |
if 'vnexpress.net' in url:data=scrape_vne_article(url)
|
| 359 |
elif 'bbc.com' in url:data=scrape_bbc_article(url)
|
| 360 |
elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
|
|
|
|
| 350 |
|
| 351 |
@app.get('/api/article')
|
| 352 |
def api_article_v2(url:str=Query(...)):
|
| 353 |
+
from main import scrape_vne_article,scrape_bbc_article,scrape_dantri_article,scrape_genk_article,scrape_ttvh_article
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
if 'vnexpress.net' in url:data=scrape_vne_article(url)
|
| 355 |
elif 'bbc.com' in url:data=scrape_bbc_article(url)
|
| 356 |
elif 'dantri.com.vn' in url:data=scrape_dantri_article(url)
|
|
@@ -21,22 +21,22 @@ async function loadHome(){
|
|
| 21 |
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
| 22 |
+'<div id="hashtag-box"></div>'
|
| 23 |
+'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
|
| 24 |
-
|
| 25 |
+'<div id="home-after-wc"></div>';
|
| 26 |
|
| 27 |
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 28 |
|
| 29 |
loadLivescore('today');
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
_fetchWithTimeout('/api/
|
| 37 |
-
_fetchWithTimeout('/api/
|
| 38 |
-
_fetchWithTimeout('/api/
|
| 39 |
-
_fetchWithTimeout('/api/
|
| 40 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 41 |
|
| 42 |
if(featuredData && featuredData.home){
|
|
@@ -46,15 +46,19 @@ async function loadHome(){
|
|
| 46 |
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featuredData.home_logo}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${featuredData.away_logo}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
|
| 47 |
}
|
| 48 |
|
|
|
|
| 49 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 50 |
_hlLeagueData = hlLeagues || {};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
|
|
|
| 52 |
_renderWallIn(afterEl);
|
| 53 |
_renderHLIn(afterEl);
|
| 54 |
if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
|
| 55 |
}
|
| 56 |
-
window.loadHome = loadHome;
|
| 57 |
-
|
| 58 |
|
| 59 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
| 60 |
if(!vids||!vids.length||!afterEl) return;
|
|
@@ -212,6 +216,7 @@ function prependWallPost(post){
|
|
| 212 |
|
| 213 |
let _shortsData=[];
|
| 214 |
let _wallPosts=[];
|
|
|
|
| 215 |
let _currentView='home';
|
| 216 |
let _currentEventId=null;
|
| 217 |
let _currentMatchUrl=null;
|
|
@@ -332,9 +337,4 @@ async function loadCat(id){const el=document.getElementById('view-cat');el.inner
|
|
| 332 |
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
|
| 333 |
(function(){try{var hash=window.location.hash;if(hash&&hash.length>1){var articleUrl=decodeURIComponent(hash.substring(1));if(articleUrl.startsWith('http')){history.replaceState(null,'',window.location.pathname);setTimeout(function(){if(typeof readArticle==='function')readArticle(articleUrl);},1500);}}}catch(e){}})();
|
| 334 |
(function(){try{const pa=localStorage.getItem('pending_article');const pv=localStorage.getItem('pending_video');if(pa){localStorage.removeItem('pending_article');setTimeout(()=>{if(typeof readArticle==='function')readArticle(pa);},1500);}if(pv){localStorage.removeItem('pending_video');try{const v=JSON.parse(pv);if(v&&v.url)setTimeout(()=>{window.open(v.url,'_blank')},1500);}catch(e){}}}catch(e){}})();
|
| 335 |
-
|
| 336 |
-
if(document.readyState==='loading'){
|
| 337 |
-
document.addEventListener('DOMContentLoaded',function(){if(typeof window.loadHome==='function')window.loadHome();});
|
| 338 |
-
}else{
|
| 339 |
-
if(typeof window.loadHome==='function')window.loadHome();
|
| 340 |
-
}
|
|
|
|
| 21 |
+'<div class="ai-compose"><div class="ai-compose-title">🤖 AI viết bài</div><div class="ai-compose-row"><input id="topic-input" placeholder="Nhập chủ đề..."><button onclick="searchTopic()">Tìm nguồn</button></div><div class="ai-compose-row"><input id="url-input" placeholder="Dán URL bài viết..."><button class="secondary" onclick="rewriteUrl()">Rewrite</button></div><div id="hot-topics" class="hot-topic-row"></div></div>'
|
| 22 |
+'<div id="hashtag-box"></div>'
|
| 23 |
+'<div class="ls-section"><div class="ls-header"><h3>⚽ Livescore</h3></div><div class="ls-tabs"><span class="ls-tab active" data-tab="today" onclick="loadLivescore(\'today\')">📅 Hôm nay</span><span class="ls-tab" data-tab="live" onclick="loadLivescore(\'live\')">🔴 Live</span><span class="ls-tab" data-tab="incoming" onclick="loadLivescore(\'incoming\')">⏰ Sắp tới</span><span class="ls-tab" data-tab="results" onclick="loadLivescore(\'results\')">✅ Kết quả</span><span class="ls-tab" data-tab="bxh_nha" onclick="loadLivescore(\'bxh_nha\')">🏆 NHA</span><span class="ls-tab" data-tab="bxh_laliga" onclick="loadLivescore(\'bxh_laliga\')">🏆 La Liga</span></div><div class="ls-content" id="ls-content"><div class="loading">Đang tải...</div></div></div>'
|
| 24 |
+
+'<div id="wc2026-live-section" class="wc2026-section"><div class="wc-header"><h2>🏆 World Cup 2026</h2><span class="wc-live-badge">● LIVE</span></div><div class="wc-tabs"><span class="wc-tab active" onclick="switchWCTab(\'news\')">📰 Tin tức</span><span class="wc-tab" onclick="switchWCTab(\'fixtures\')">📅 Lịch thi đấu</span><span class="wc-tab" onclick="switchWCTab(\'standings\')">🏆 BXH</span><span class="wc-tab" onclick="switchWCTab(\'highlights\')">🎬 Highlight</span><span class="wc-tab" onclick="switchWCTab(\'shorts\')">📱 Shorts</span><span class="wc-tab" onclick="switchWCTab(\'stats\')">📊 Thống kê</span></div><div class="wc-content" id="wc-content"><div class="loading">Đang tải World Cup 2026...</div></div></div>'
|
| 25 |
+'<div id="home-after-wc"></div>';
|
| 26 |
|
| 27 |
const afterEl = homeEl.querySelector('#home-after-wc');
|
| 28 |
|
| 29 |
loadLivescore('today');
|
| 30 |
+
loadHotTopics();
|
| 31 |
|
| 32 |
+
const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData, vtvShortsData] = await Promise.allSettled([
|
| 33 |
+
_fetchWithTimeout('/api/livescore/featured', 5000),
|
| 34 |
+
_fetchWithTimeout('/api/shorts', 8000),
|
| 35 |
+
_fetchWithTimeout('/api/wall', 5000),
|
| 36 |
+
_fetchWithTimeout('/api/highlights/leagues', 10000),
|
| 37 |
+
_fetchWithTimeout('/api/genk_ai', 8000),
|
| 38 |
+
_fetchWithTimeout('/api/wc2026', 8000),
|
| 39 |
+
_fetchWithTimeout('/api/shorts/vtvnamo?count=50', 12000),
|
| 40 |
]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
|
| 41 |
|
| 42 |
if(featuredData && featuredData.home){
|
|
|
|
| 46 |
if(area) area.innerHTML=`<div class="featured-match" onclick="openMatch('${featuredData.event_id}')"><div class="fm-league">${featuredData.league}</div><div class="fm-teams"><div class="fm-team"><img src="${featuredData.home_logo}" onerror="this.style.display='none'"><span>${featuredData.home}</span></div><div class="fm-score">${featuredData.score||'VS'}</div><div class="fm-team"><img src="${featuredData.away_logo}" onerror="this.style.display='none'"><span>${featuredData.away}</span></div></div><div class="fm-status ${sc}">${st}</div></div>`;
|
| 47 |
}
|
| 48 |
|
| 49 |
+
_shortsData = shortsData || [];
|
| 50 |
_wallPosts = (wallData && wallData.posts) || [];
|
| 51 |
_hlLeagueData = hlLeagues || {};
|
| 52 |
+
_wc2026Data = wcData;
|
| 53 |
+
_wcShortsData = vtvShortsData || [];
|
| 54 |
+
|
| 55 |
+
if(wcData) switchWCTab('news');
|
| 56 |
|
| 57 |
+
_renderShortsIn(afterEl);
|
| 58 |
_renderWallIn(afterEl);
|
| 59 |
_renderHLIn(afterEl);
|
| 60 |
if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl);
|
| 61 |
}
|
|
|
|
|
|
|
| 62 |
|
| 63 |
function _renderSlidesIn(key, label, emoji, vids, afterEl){
|
| 64 |
if(!vids||!vids.length||!afterEl) return;
|
|
|
|
| 216 |
|
| 217 |
let _shortsData=[];
|
| 218 |
let _wallPosts=[];
|
| 219 |
+
let _wcShortsData=[];
|
| 220 |
let _currentView='home';
|
| 221 |
let _currentEventId=null;
|
| 222 |
let _currentMatchUrl=null;
|
|
|
|
| 337 |
fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{});
|
| 338 |
(function(){try{var hash=window.location.hash;if(hash&&hash.length>1){var articleUrl=decodeURIComponent(hash.substring(1));if(articleUrl.startsWith('http')){history.replaceState(null,'',window.location.pathname);setTimeout(function(){if(typeof readArticle==='function')readArticle(articleUrl);},1500);}}}catch(e){}})();
|
| 339 |
(function(){try{const pa=localStorage.getItem('pending_article');const pv=localStorage.getItem('pending_video');if(pa){localStorage.removeItem('pending_article');setTimeout(()=>{if(typeof readArticle==='function')readArticle(pa);},1500);}if(pv){localStorage.removeItem('pending_video');try{const v=JSON.parse(pv);if(v&&v.url)setTimeout(()=>{window.open(v.url,'_blank')},1500);}catch(e){}}}catch(e){}})();
|
| 340 |
+
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){if(typeof loadHome==='function')loadHome();});}else{if(typeof loadHome==='function')loadHome();}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -30,7 +30,7 @@
|
|
| 30 |
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 31 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 32 |
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 33 |
-
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')
|
| 34 |
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 35 |
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
|
| 36 |
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+=`<div class="cat" data-cat="${c.id}">${c.name}</div>`});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
|
@@ -39,8 +39,11 @@ var SPACE=location.origin;
|
|
| 39 |
<script src="/static/app_v2.js"></script>
|
| 40 |
<script src="/static/yt_live.js"></script>
|
| 41 |
<script src="/static/hot_multi.js"></script>
|
|
|
|
| 42 |
<script src="/static/live_mode.js"></script>
|
| 43 |
<script src="/static/match_detail_v6.js"></script>
|
|
|
|
|
|
|
| 44 |
<script>init();</script>
|
| 45 |
</body>
|
| 46 |
</html>
|
|
|
|
| 30 |
var _cats=[],_hlLeagueData={},_currentArticle=null;window._currentEventId='';
|
| 31 |
function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))}
|
| 32 |
function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')}
|
| 33 |
+
function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src=''});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}}
|
| 34 |
function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}}
|
| 35 |
function doShare(title,url,img){const shareUrl=SPACE+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||'');if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép!')).catch(()=>{})}
|
| 36 |
async function init(){_cats=await fetch('/api/categories').then(r=>r.json()).catch(()=>[]);let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="news-all">📰 Tin tức</div>';_cats.forEach(c=>{bar+=`<div class="cat" data-cat="${c.id}">${c.name}</div>`});document.getElementById('cat-bar').innerHTML=bar;document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat)});}
|
|
|
|
| 39 |
<script src="/static/app_v2.js"></script>
|
| 40 |
<script src="/static/yt_live.js"></script>
|
| 41 |
<script src="/static/hot_multi.js"></script>
|
| 42 |
+
<script src="/static/wc2026_v2.js"></script>
|
| 43 |
<script src="/static/live_mode.js"></script>
|
| 44 |
<script src="/static/match_detail_v6.js"></script>
|
| 45 |
+
<script src="/static/wc_shorts_slide.js"></script>
|
| 46 |
+
<script src="/static/wc_shorts_inject.js"></script>
|
| 47 |
<script>init();</script>
|
| 48 |
</body>
|
| 49 |
</html>
|
|
@@ -1 +1,76 @@
|
|
| 1 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WC Shorts Inject — Chạy SAU app_v2.js
|
| 3 |
+
* Tự tạo anchor DIV rồi render WC Shorts slide TRÊN World Cup section
|
| 4 |
+
*/
|
| 5 |
+
(function(){
|
| 6 |
+
'use strict';
|
| 7 |
+
|
| 8 |
+
function inject(){
|
| 9 |
+
// Tìm WC section
|
| 10 |
+
const wcSection = document.getElementById('wc2026-live-section');
|
| 11 |
+
const homeEl = document.getElementById('view-home');
|
| 12 |
+
if(!homeEl) return;
|
| 13 |
+
|
| 14 |
+
// Đã inject?
|
| 15 |
+
if(document.getElementById('wc-shorts-slide-section')) return;
|
| 16 |
+
|
| 17 |
+
// Đảm bảo renderWCSSlideSection đã load
|
| 18 |
+
if(typeof renderWCSSlideSection !== 'function'){
|
| 19 |
+
// Chờ rồi thử lại
|
| 20 |
+
setTimeout(inject, 500);
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Tạo container rồi render vào
|
| 25 |
+
const div = document.createElement('div');
|
| 26 |
+
div.id = 'wc-shorts-slide-container-tmp';
|
| 27 |
+
div.style.display = 'none';
|
| 28 |
+
|
| 29 |
+
// Insert TRƯỚC WC section, hoặc vào đầu home
|
| 30 |
+
if(wcSection && wcSection.parentNode){
|
| 31 |
+
wcSection.parentNode.insertBefore(div, wcSection);
|
| 32 |
+
} else {
|
| 33 |
+
homeEl.insertBefore(div, homeEl.firstChild);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Render slide section vào div
|
| 37 |
+
renderWCSSlideSection(div);
|
| 38 |
+
|
| 39 |
+
// Bỏ wrapper thừa, di chuyển section ra đúng vị trí
|
| 40 |
+
const section = div.querySelector('.wc-shorts-slide-section');
|
| 41 |
+
if(section){
|
| 42 |
+
section.id = 'wc-shorts-slide-section';
|
| 43 |
+
if(wcSection && wcSection.parentNode){
|
| 44 |
+
wcSection.parentNode.insertBefore(section, wcSection);
|
| 45 |
+
}
|
| 46 |
+
div.remove();
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Chạy khi DOM sẵn sàng
|
| 51 |
+
if(document.readyState === 'loading'){
|
| 52 |
+
document.addEventListener('DOMContentLoaded', ()=> setTimeout(inject, 800));
|
| 53 |
+
} else {
|
| 54 |
+
setTimeout(inject, 800);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Cũng chạy sau khi loadHome xong
|
| 58 |
+
const origLoadHome = window.loadHome;
|
| 59 |
+
if(origLoadHome){
|
| 60 |
+
window.loadHome = function(){
|
| 61 |
+
const result = origLoadHome.apply(this, arguments);
|
| 62 |
+
setTimeout(inject, 200);
|
| 63 |
+
setTimeout(inject, 800);
|
| 64 |
+
setTimeout(inject, 2000);
|
| 65 |
+
return result;
|
| 66 |
+
};
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Fallback: retry nhiều lần
|
| 70 |
+
let retries = 0;
|
| 71 |
+
const retryIv = setInterval(()=>{
|
| 72 |
+
retries++;
|
| 73 |
+
inject();
|
| 74 |
+
if(retries > 20) clearInterval(retryIv);
|
| 75 |
+
}, 1000);
|
| 76 |
+
})();
|
|
@@ -1 +1,597 @@
|
|
| 1 |
-
/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* WC Shorts Slide — A standalone slide section ABOVE the World Cup section
|
| 3 |
+
* TikTok-style vertical feed with autoplay, titles, interaction buttons
|
| 4 |
+
* Each short = 1 fullscreen slide with scroll-snap
|
| 5 |
+
*/
|
| 6 |
+
(function(){
|
| 7 |
+
'use strict';
|
| 8 |
+
|
| 9 |
+
// ===== Inject CSS =====
|
| 10 |
+
if(document.getElementById('wc-shorts-slide-css')) return;
|
| 11 |
+
const style = document.createElement('style');
|
| 12 |
+
style.id = 'wc-shorts-slide-css';
|
| 13 |
+
style.textContent = `
|
| 14 |
+
/* === WC SHORTS SLIDE SECTION (above WC section) === */
|
| 15 |
+
.wc-shorts-slide-section {
|
| 16 |
+
margin: 6px 4px;
|
| 17 |
+
background: linear-gradient(135deg, #000 0%, #0a0a2e 50%, #0d1117 100%);
|
| 18 |
+
border: 1px solid #1a3a5a;
|
| 19 |
+
border-radius: 10px;
|
| 20 |
+
overflow: hidden;
|
| 21 |
+
}
|
| 22 |
+
.wc-shorts-slide-header {
|
| 23 |
+
display: flex;
|
| 24 |
+
align-items: center;
|
| 25 |
+
justify-content: space-between;
|
| 26 |
+
padding: 10px 12px 6px;
|
| 27 |
+
}
|
| 28 |
+
.wc-shorts-slide-header h3 {
|
| 29 |
+
font-size: 15px;
|
| 30 |
+
color: #fff;
|
| 31 |
+
font-weight: 800;
|
| 32 |
+
margin: 0;
|
| 33 |
+
display: flex;
|
| 34 |
+
align-items: center;
|
| 35 |
+
gap: 6px;
|
| 36 |
+
}
|
| 37 |
+
.wc-shorts-slide-header h3 .emoji { font-size: 18px; }
|
| 38 |
+
.wc-shorts-slide-header .badge-live {
|
| 39 |
+
font-size: 9px;
|
| 40 |
+
color: #e74c3c;
|
| 41 |
+
font-weight: 700;
|
| 42 |
+
animation: wc-slide-pulse 1.5s infinite;
|
| 43 |
+
}
|
| 44 |
+
@keyframes wc-slide-pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
| 45 |
+
.wc-shorts-slide-header .view-all-btn {
|
| 46 |
+
background: #0b6bcb;
|
| 47 |
+
border: none;
|
| 48 |
+
color: #fff;
|
| 49 |
+
padding: 5px 12px;
|
| 50 |
+
border-radius: 12px;
|
| 51 |
+
font-size: 10px;
|
| 52 |
+
font-weight: 700;
|
| 53 |
+
cursor: pointer;
|
| 54 |
+
white-space: nowrap;
|
| 55 |
+
}
|
| 56 |
+
.wc-shorts-slide-header .view-all-btn:active { opacity: .8; }
|
| 57 |
+
|
| 58 |
+
/* Horizontal thumbnail slider */
|
| 59 |
+
.wc-shorts-h-slider {
|
| 60 |
+
display: flex;
|
| 61 |
+
overflow-x: auto;
|
| 62 |
+
gap: 8px;
|
| 63 |
+
padding: 4px 10px 12px;
|
| 64 |
+
scroll-snap-type: x mandatory;
|
| 65 |
+
scrollbar-width: none;
|
| 66 |
+
}
|
| 67 |
+
.wc-shorts-h-slider::-webkit-scrollbar { display: none; }
|
| 68 |
+
.wc-shorts-h-item {
|
| 69 |
+
flex: 0 0 130px;
|
| 70 |
+
scroll-snap-align: start;
|
| 71 |
+
cursor: pointer;
|
| 72 |
+
border-radius: 8px;
|
| 73 |
+
overflow: hidden;
|
| 74 |
+
background: #0a1520;
|
| 75 |
+
border: 1px solid #1a3a5a;
|
| 76 |
+
transition: border-color .2s, transform .2s;
|
| 77 |
+
position: relative;
|
| 78 |
+
}
|
| 79 |
+
.wc-shorts-h-item:hover { border-color: #0b6bcb; transform: scale(1.03); }
|
| 80 |
+
.wc-shorts-h-item:active { transform: scale(.97); }
|
| 81 |
+
.wc-shorts-h-thumb {
|
| 82 |
+
position: relative;
|
| 83 |
+
width: 100%;
|
| 84 |
+
aspect-ratio: 9/16;
|
| 85 |
+
background: #1a2a3a;
|
| 86 |
+
overflow: hidden;
|
| 87 |
+
}
|
| 88 |
+
.wc-shorts-h-thumb img {
|
| 89 |
+
width: 100%; height: 100%; object-fit: cover;
|
| 90 |
+
}
|
| 91 |
+
.wc-shorts-h-thumb .h-play-icon {
|
| 92 |
+
position: absolute; left: 50%; top: 50%;
|
| 93 |
+
transform: translate(-50%, -50%);
|
| 94 |
+
width: 36px; height: 36px;
|
| 95 |
+
border-radius: 50%;
|
| 96 |
+
background: rgba(0,0,0,.6);
|
| 97 |
+
display: flex; align-items: center; justify-content: center;
|
| 98 |
+
color: #fff; font-size: 14px;
|
| 99 |
+
}
|
| 100 |
+
.wc-shorts-h-thumb .h-duration {
|
| 101 |
+
position: absolute; bottom: 4px; right: 4px;
|
| 102 |
+
background: rgba(0,0,0,.7);
|
| 103 |
+
font-size: 8px; color: #fff;
|
| 104 |
+
padding: 1px 4px; border-radius: 3px;
|
| 105 |
+
}
|
| 106 |
+
.wc-shorts-h-thumb .h-vtv-badge {
|
| 107 |
+
position: absolute; top: 4px; left: 4px;
|
| 108 |
+
background: rgba(11,107,203,.85);
|
| 109 |
+
font-size: 7px; color: #fff;
|
| 110 |
+
padding: 1px 5px; border-radius: 3px;
|
| 111 |
+
font-weight: 700;
|
| 112 |
+
}
|
| 113 |
+
.wc-shorts-h-info { padding: 5px 6px; }
|
| 114 |
+
.wc-shorts-h-info .h-title {
|
| 115 |
+
font-size: 9px; color: #ccc; line-height: 1.25;
|
| 116 |
+
display: -webkit-box; -webkit-line-clamp: 2;
|
| 117 |
+
-webkit-box-orient: vertical; overflow: hidden;
|
| 118 |
+
}
|
| 119 |
+
.wc-shorts-empty-slider {
|
| 120 |
+
text-align: center; padding: 20px 10px; color: #666; font-size: 12px;
|
| 121 |
+
width: 100%;
|
| 122 |
+
}
|
| 123 |
+
.wc-shorts-empty-slider .reload-btn {
|
| 124 |
+
background: #0b6bcb; border: none; color: #fff;
|
| 125 |
+
padding: 6px 16px; border-radius: 14px; font-size: 11px;
|
| 126 |
+
font-weight: 700; cursor: pointer; margin-top: 10px;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* === FULL-SCREEN TIKTOK OVERLAY (opened from slide) === */
|
| 130 |
+
.wc-st-overlay {
|
| 131 |
+
position: fixed; inset: 0; background: #000;
|
| 132 |
+
z-index: 99999; display: none; flex-direction: column;
|
| 133 |
+
}
|
| 134 |
+
.wc-st-overlay.active { display: flex; }
|
| 135 |
+
.wc-st-top-bar {
|
| 136 |
+
position: absolute; top: 0; left: 0; right: 0;
|
| 137 |
+
z-index: 10; padding: 10px 12px;
|
| 138 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 139 |
+
background: linear-gradient(rgba(0,0,0,.7), transparent);
|
| 140 |
+
}
|
| 141 |
+
.wc-st-top-bar .back-btn {
|
| 142 |
+
background: rgba(255,255,255,.15);
|
| 143 |
+
border: none; color: #fff;
|
| 144 |
+
width: 36px; height: 36px; border-radius: 50%;
|
| 145 |
+
font-size: 18px; cursor: pointer;
|
| 146 |
+
display: flex; align-items: center; justify-content: center;
|
| 147 |
+
}
|
| 148 |
+
.wc-st-top-bar .st-counter {
|
| 149 |
+
color: #fff; font-size: 12px; font-weight: 600;
|
| 150 |
+
background: rgba(0,0,0,.4); padding: 3px 10px;
|
| 151 |
+
border-radius: 10px; backdrop-filter: blur(4px);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.wc-st-feed {
|
| 155 |
+
flex: 1; overflow-y: scroll;
|
| 156 |
+
scroll-snap-type: y mandatory;
|
| 157 |
+
scrollbar-width: none;
|
| 158 |
+
}
|
| 159 |
+
.wc-st-feed::-webkit-scrollbar { display: none; }
|
| 160 |
+
|
| 161 |
+
.wc-st-slide {
|
| 162 |
+
height: 100vh; width: 100%;
|
| 163 |
+
scroll-snap-align: start;
|
| 164 |
+
position: relative;
|
| 165 |
+
background: #000;
|
| 166 |
+
display: flex; align-items: center; justify-content: center;
|
| 167 |
+
overflow: hidden;
|
| 168 |
+
}
|
| 169 |
+
.wc-st-slide iframe {
|
| 170 |
+
width: 100%; height: 100%; border: none; background: #000;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* Title & info overlay */
|
| 174 |
+
.wc-st-info {
|
| 175 |
+
position: absolute; bottom: 0; left: 0; right: 65px;
|
| 176 |
+
padding: 12px 14px 18px;
|
| 177 |
+
background: linear-gradient(transparent 0%, rgba(0,0,0,.65) 35%, rgba(0,0,0,.9) 100%);
|
| 178 |
+
z-index: 3; pointer-events: none;
|
| 179 |
+
}
|
| 180 |
+
.wc-st-info .st-channel {
|
| 181 |
+
display: inline-block; font-size: 9px; font-weight: 700;
|
| 182 |
+
padding: 2px 8px; border-radius: 10px; color: #fff;
|
| 183 |
+
background: #0b6bcb; margin-bottom: 8px;
|
| 184 |
+
}
|
| 185 |
+
.wc-st-info .st-title {
|
| 186 |
+
font-size: 14px; color: #fff; font-weight: 700;
|
| 187 |
+
line-height: 1.35; text-shadow: 0 1px 4px rgba(0,0,0,.7);
|
| 188 |
+
display: -webkit-box; -webkit-line-clamp: 3;
|
| 189 |
+
-webkit-box-orient: vertical; overflow: hidden;
|
| 190 |
+
}
|
| 191 |
+
.wc-st-info .st-link {
|
| 192 |
+
display: inline-block; margin-top: 8px;
|
| 193 |
+
font-size: 11px; color: #8ab4d8; text-decoration: underline;
|
| 194 |
+
pointer-events: auto; cursor: pointer;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* Right-side action buttons */
|
| 198 |
+
.wc-st-actions {
|
| 199 |
+
position: absolute; right: 8px; bottom: 90px;
|
| 200 |
+
display: flex; flex-direction: column; align-items: center;
|
| 201 |
+
gap: 16px; z-index: 5;
|
| 202 |
+
}
|
| 203 |
+
.wc-st-action {
|
| 204 |
+
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
| 205 |
+
background: none; border: none; color: #fff; cursor: pointer;
|
| 206 |
+
font-size: 10px; text-shadow: 0 1px 3px rgba(0,0,0,.5);
|
| 207 |
+
}
|
| 208 |
+
.wc-st-action .a-icon {
|
| 209 |
+
width: 46px; height: 46px; border-radius: 50%;
|
| 210 |
+
background: rgba(255,255,255,.15);
|
| 211 |
+
display: flex; align-items: center; justify-content: center;
|
| 212 |
+
font-size: 20px; backdrop-filter: blur(4px);
|
| 213 |
+
transition: background .2s, transform .15s;
|
| 214 |
+
}
|
| 215 |
+
.wc-st-action:active .a-icon { transform: scale(.88); background: rgba(255,255,255,.3); }
|
| 216 |
+
.wc-st-action .a-count { font-size: 10px; color: #eee; font-weight: 600; }
|
| 217 |
+
|
| 218 |
+
/* Loading state */
|
| 219 |
+
.wc-st-loading {
|
| 220 |
+
position: absolute; inset: 0; display: flex;
|
| 221 |
+
flex-direction: column; align-items: center; justify-content: center;
|
| 222 |
+
gap: 12px; color: #888; font-size: 13px;
|
| 223 |
+
}
|
| 224 |
+
.wc-st-loading .spinner {
|
| 225 |
+
width: 28px; height: 28px;
|
| 226 |
+
border: 3px solid #333; border-top-color: #0b6bcb;
|
| 227 |
+
border-radius: 50%; animation: wcst-spin .8s linear infinite;
|
| 228 |
+
}
|
| 229 |
+
@keyframes wcst-spin { to { transform: rotate(360deg); } }
|
| 230 |
+
|
| 231 |
+
/* No shorts message */
|
| 232 |
+
.wc-st-nosho {
|
| 233 |
+
position: absolute; inset: 0; display: flex;
|
| 234 |
+
flex-direction: column; align-items: center; justify-content: center;
|
| 235 |
+
gap: 10px; color: #666; font-size: 14px; text-align: center; padding: 20px;
|
| 236 |
+
}
|
| 237 |
+
.wc-st-nosho .icon { font-size: 48px; }
|
| 238 |
+
.wc-st-nosho .retry-btn {
|
| 239 |
+
background: #0b6bcb; border: none; color: #fff;
|
| 240 |
+
padding: 10px 28px; border-radius: 20px;
|
| 241 |
+
font-size: 13px; font-weight: 700; cursor: pointer;
|
| 242 |
+
}
|
| 243 |
+
`;
|
| 244 |
+
document.head.appendChild(style);
|
| 245 |
+
|
| 246 |
+
// ===== State =====
|
| 247 |
+
let _wcSCached = null; // cached shorts data
|
| 248 |
+
let _wcSOverlayTimer = null; // auto-advance timer
|
| 249 |
+
let _wcSManualScroll = false;
|
| 250 |
+
|
| 251 |
+
// ===== Fetch shorts =====
|
| 252 |
+
async function fetchShorts(){
|
| 253 |
+
const endpoints = [
|
| 254 |
+
'/api/shorts/wc?count=50',
|
| 255 |
+
'/api/shorts/vtvnamo?count=50',
|
| 256 |
+
'/api/shorts?channel=vtvnambo',
|
| 257 |
+
];
|
| 258 |
+
for(const ep of endpoints){
|
| 259 |
+
try{
|
| 260 |
+
const r = await fetch(ep, {signal: AbortSignal.timeout(12000)});
|
| 261 |
+
const data = await r.json();
|
| 262 |
+
if(Array.isArray(data) && data.length > 0){
|
| 263 |
+
// Deduplicate
|
| 264 |
+
const seen = new Set(), deduped = [];
|
| 265 |
+
for(const s of data){
|
| 266 |
+
if(!seen.has(s.id)){ seen.add(s.id); deduped.push(s); }
|
| 267 |
+
}
|
| 268 |
+
if(deduped.length > 0) return deduped;
|
| 269 |
+
}
|
| 270 |
+
}catch(e){ console.warn('WC Shorts fetch failed for', ep, e); }
|
| 271 |
+
}
|
| 272 |
+
return [];
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// ===== Render the HORIZONTAL SLIDE (placed above WC section) =====
|
| 276 |
+
window.renderWCSSlideSection = async function(container){
|
| 277 |
+
const section = document.createElement('div');
|
| 278 |
+
section.className = 'wc-shorts-slide-section';
|
| 279 |
+
section.id = 'wc-shorts-slide-section';
|
| 280 |
+
|
| 281 |
+
// Header
|
| 282 |
+
section.innerHTML = `
|
| 283 |
+
<div class="wc-shorts-slide-header">
|
| 284 |
+
<h3><span class="emoji">📱</span> Shorts World Cup <span class="badge-live">● LIVE</span></h3>
|
| 285 |
+
<button class="view-all-btn" id="wc-st-viewall">Xem tất cả →</button>
|
| 286 |
+
</div>
|
| 287 |
+
<div class="wc-shorts-h-slider" id="wc-st-hslider">
|
| 288 |
+
<div style="width:100%;text-align:center;padding:20px;color:#666;font-size:12px">
|
| 289 |
+
<div class="spinner" style="width:20px;height:20px;border:2px solid #333;border-top-color:#0b6bcb;border-radius:50%;animation:wcst-spin .8s linear infinite;margin:0 auto 8px"></div>
|
| 290 |
+
Đang tải shorts...
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
`;
|
| 294 |
+
container.appendChild(section);
|
| 295 |
+
|
| 296 |
+
const slider = section.querySelector('#wc-st-hslider');
|
| 297 |
+
const viewAllBtn = section.querySelector('#wc-st-viewall');
|
| 298 |
+
|
| 299 |
+
// Fetch data
|
| 300 |
+
let shorts = await fetchShorts();
|
| 301 |
+
_wcSCached = shorts;
|
| 302 |
+
|
| 303 |
+
if(!shorts || !shorts.length){
|
| 304 |
+
slider.innerHTML = `<div class="wc-shorts-empty-slider">
|
| 305 |
+
<div>📱</div><div style="margin-top:6px;color:#888">Đang cập nhật shorts...</div>
|
| 306 |
+
<button class="reload-btn" onclick="renderWCSSlideSection(document.getElementById('wc-shorts-slide-section').parentNode)">Thử lại</button>
|
| 307 |
+
</div>`;
|
| 308 |
+
viewAllBtn.onclick = () => openWCSF(shorts || [], 0);
|
| 309 |
+
return;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// Render horizontal slider items
|
| 313 |
+
let h = '';
|
| 314 |
+
shorts.forEach((s, i)=>{
|
| 315 |
+
const thumbImg = s.img || `https://i.ytimg.com/vi/${s.id}/hqdefault.jpg`;
|
| 316 |
+
h += `<div class="wc-shorts-h-item" data-idx="${i}" onclick="openWCSF(_wcSCached, ${i})">`;
|
| 317 |
+
h += `<div class="wc-shorts-h-thumb">`;
|
| 318 |
+
h += `<img src="${escHtml(thumbImg)}" loading="lazy" onerror="this.style.display='none'">`;
|
| 319 |
+
h += `<span class="h-vtv-badge">VTV Nam Bộ</span>`;
|
| 320 |
+
h += `<div class="h-play-icon">▶</div>`;
|
| 321 |
+
h += `</div>`;
|
| 322 |
+
h += `<div class="wc-shorts-h-info"><div class="h-title">${escHtml(s.title)}</div></div>`;
|
| 323 |
+
h += `</div>`;
|
| 324 |
+
});
|
| 325 |
+
slider.innerHTML = h;
|
| 326 |
+
|
| 327 |
+
viewAllBtn.onclick = () => openWCSF(shorts, 0);
|
| 328 |
+
};
|
| 329 |
+
|
| 330 |
+
// ===== FULL-SCREEN TIKTOK OVERLAY =====
|
| 331 |
+
window.openWCSF = function(shorts, startIdx){
|
| 332 |
+
if(!shorts || !shorts.length){
|
| 333 |
+
// Try to fetch fresh
|
| 334 |
+
openWCSFLoading(startIdx);
|
| 335 |
+
return;
|
| 336 |
+
}
|
| 337 |
+
buildWCSF(shorts, startIdx);
|
| 338 |
+
};
|
| 339 |
+
|
| 340 |
+
function openWCSFLoading(startIdx){
|
| 341 |
+
const overlay = getOrCreateWCSFOverlay();
|
| 342 |
+
overlay.classList.add('active');
|
| 343 |
+
overlay.innerHTML = `<div class="wc-st-loading" style="position:absolute;inset:0;background:#000;z-index:100">
|
| 344 |
+
<div class="spinner"></div>Đang tải shorts...
|
| 345 |
+
</div>`;
|
| 346 |
+
fetchShorts().then(shorts => {
|
| 347 |
+
_wcSCached = shorts;
|
| 348 |
+
if(shorts && shorts.length){
|
| 349 |
+
buildWCSF(shorts, startIdx || 0);
|
| 350 |
+
} else {
|
| 351 |
+
overlay.innerHTML = `<div class="wc-st-nosho">
|
| 352 |
+
<div class="icon">📱</div><div>Không tải được shorts</div>
|
| 353 |
+
<button class="retry-btn" onclick="openWCSF(_wcSCached, ${startIdx||0})">Thử lại</button>
|
| 354 |
+
<button class="retry-btn" style="background:#333;margin-top:4px" onclick="closeWCSF()">Đóng</button>
|
| 355 |
+
</div>`;
|
| 356 |
+
}
|
| 357 |
+
}).catch(() => {
|
| 358 |
+
overlay.innerHTML = `<div class="wc-st-nosho">
|
| 359 |
+
<div class="icon">📱</div><div>Lỗi kết nối</div>
|
| 360 |
+
<button class="retry-btn" onclick="openWCSF(_wcSCached, ${startIdx||0})">Thử lại</button>
|
| 361 |
+
</div>`;
|
| 362 |
+
});
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
function getOrCreateWCSFOverlay(){
|
| 366 |
+
let overlay = document.getElementById('wc-st-overlay');
|
| 367 |
+
if(!overlay){
|
| 368 |
+
overlay = document.createElement('div');
|
| 369 |
+
overlay.id = 'wc-st-overlay';
|
| 370 |
+
overlay.className = 'wc-st-overlay';
|
| 371 |
+
document.body.appendChild(overlay);
|
| 372 |
+
}
|
| 373 |
+
return overlay;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
window.closeWCSF = function(){
|
| 377 |
+
const overlay = document.getElementById('wc-st-overlay');
|
| 378 |
+
if(!overlay) return;
|
| 379 |
+
overlay.classList.remove('active');
|
| 380 |
+
overlay.innerHTML = '';
|
| 381 |
+
if(_wcSOverlayTimer){ clearInterval(_wcSOverlayTimer); _wcSOverlayTimer = null; }
|
| 382 |
+
// Stop any playing iframes
|
| 383 |
+
overlay.querySelectorAll('iframe').forEach(f => { f.src = ''; });
|
| 384 |
+
};
|
| 385 |
+
|
| 386 |
+
function buildWCSF(shorts, startIdx){
|
| 387 |
+
const overlay = getOrCreateWCSFOverlay();
|
| 388 |
+
overlay.classList.add('active');
|
| 389 |
+
|
| 390 |
+
// Rotate so startIdx is first
|
| 391 |
+
const ordered = startIdx > 0
|
| 392 |
+
? [...shorts.slice(startIdx), ...shorts.slice(0, startIdx)]
|
| 393 |
+
: [...shorts];
|
| 394 |
+
const total = ordered.length;
|
| 395 |
+
|
| 396 |
+
// Build HTML
|
| 397 |
+
let h = '';
|
| 398 |
+
// Top bar
|
| 399 |
+
h += `<div class="wc-st-top-bar">
|
| 400 |
+
<button class="back-btn" onclick="closeWCSF()">←</button>
|
| 401 |
+
<span class="st-counter" id="wc-st-counter">1/${total}</span>
|
| 402 |
+
</div>`;
|
| 403 |
+
|
| 404 |
+
// Feed
|
| 405 |
+
h += `<div class="wc-st-feed" id="wc-st-feed">`;
|
| 406 |
+
|
| 407 |
+
ordered.forEach((s, i)=>{
|
| 408 |
+
const id = s.id || '';
|
| 409 |
+
const embedSrc = `https://www.youtube.com/embed/${id}?autoplay=0&rel=0&playsinline=1&mute=1&loop=1&playlist=${id}&enablejsapi=1&origin=${encodeURIComponent(location.origin)}`;
|
| 410 |
+
|
| 411 |
+
h += `<div class="wc-st-slide" data-vid="${escHtml('wcst-'+id)}" data-idx="${i}" data-ytid="${escHtml(id)}">`;
|
| 412 |
+
// iframe — lazy loaded, autoplay handled by scroll observer
|
| 413 |
+
h += `<iframe data-yt-src="${embedSrc}" allowfullscreen allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;fullscreen"></iframe>`;
|
| 414 |
+
|
| 415 |
+
// Info overlay (title)
|
| 416 |
+
h += `<div class="wc-st-info">`;
|
| 417 |
+
h += `<span class="st-channel">VTV Nam Bộ</span>`;
|
| 418 |
+
h += `<div class="st-title">${escHtml(s.title)}</div>`;
|
| 419 |
+
h += `<span class="st-link" onclick="window.open('https://youtube.com/shorts/${escHtml(id)}','_blank')">Mở trên YouTube →</span>`;
|
| 420 |
+
h += `</div>`;
|
| 421 |
+
|
| 422 |
+
// Right action buttons
|
| 423 |
+
h += `<div class="wc-st-actions">`;
|
| 424 |
+
h += `<button class="wc-st-action" title="Xem" onclick="event.stopPropagation();wcSTTrack('${escHtml(id)}','view',${i})"><div class="a-icon">👁</div><span class="a-count" id="wcst-vc-${i}">0</span></button>`;
|
| 425 |
+
h += `<button class="wc-st-action" title="Thích" onclick="event.stopPropagation();wcSTTrack('${escHtml(id)}','like',${i})"><div class="a-icon">❤️</div><span class="a-count" id="wcst-lc-${i}">0</span></button>`;
|
| 426 |
+
h += `<button class="wc-st-action" title="Chia sẻ" onclick="event.stopPropagation();wcSTShare('${escHtml(id)}')"><div class="a-icon">📤</div></button>`;
|
| 427 |
+
h += `<button class="wc-st-action" title="YouTube" onclick="event.stopPropagation();window.open('https://youtube.com/shorts/${escHtml(id)}','_blank')"><div class="a-icon">🔗</div></button>`;
|
| 428 |
+
h += `</div>`;
|
| 429 |
+
|
| 430 |
+
h += `</div>`;
|
| 431 |
+
});
|
| 432 |
+
|
| 433 |
+
h += `</div>`; // end feed
|
| 434 |
+
overlay.innerHTML = h;
|
| 435 |
+
|
| 436 |
+
// Init autoplay on scroll
|
| 437 |
+
initWCSFAutoplay(ordered);
|
| 438 |
+
|
| 439 |
+
// Load stats
|
| 440 |
+
loadWCSFStats(ordered);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// ===== Autoplay on scroll (intersection observer) =====
|
| 444 |
+
function initWCSFAutoplay(shorts){
|
| 445 |
+
const feed = document.getElementById('wc-st-feed');
|
| 446 |
+
if(!feed) return;
|
| 447 |
+
const slides = feed.querySelectorAll('.wc-st-slide');
|
| 448 |
+
let curIdx = -1;
|
| 449 |
+
_wcSManualScroll = false;
|
| 450 |
+
let manualTimer = null;
|
| 451 |
+
|
| 452 |
+
function activate(idx){
|
| 453 |
+
if(idx === curIdx || idx < 0 || idx >= slides.length) return;
|
| 454 |
+
slides.forEach((sl, j)=>{
|
| 455 |
+
const fr = sl.querySelector('iframe');
|
| 456 |
+
if(j === idx){
|
| 457 |
+
// Load iframe if needed
|
| 458 |
+
if(fr && !fr.src && fr.dataset.ytSrc){ fr.src = fr.dataset.ytSrc; }
|
| 459 |
+
// Play (with mute to satisfy autoplay policy)
|
| 460 |
+
if(fr && fr.src){
|
| 461 |
+
setTimeout(()=>{
|
| 462 |
+
try{
|
| 463 |
+
fr.contentWindow.postMessage(JSON.stringify({event:'command',func:'mute',args:''}),'*');
|
| 464 |
+
fr.contentWindow.postMessage(JSON.stringify({event:'command',func:'playVideo',args:''}),'*');
|
| 465 |
+
}catch(e){}
|
| 466 |
+
}, 400);
|
| 467 |
+
}
|
| 468 |
+
} else {
|
| 469 |
+
// Pause others
|
| 470 |
+
if(fr && fr.src){
|
| 471 |
+
try{ fr.contentWindow.postMessage(JSON.stringify({event:'command',func:'pauseVideo',args:''}),'*'); }catch(e){}
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
});
|
| 475 |
+
curIdx = idx;
|
| 476 |
+
// Update counter
|
| 477 |
+
const counter = document.getElementById('wc-st-counter');
|
| 478 |
+
if(counter) counter.textContent = `${idx+1}/${shorts.length}`;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
// Use IntersectionObserver for precise snap detection
|
| 482 |
+
const io = new IntersectionObserver((entries)=>{
|
| 483 |
+
if(_wcSManualScroll) return;
|
| 484 |
+
entries.forEach(entry=>{
|
| 485 |
+
if(entry.isIntersecting && entry.intersectionRatio > 0.6){
|
| 486 |
+
const idx = parseInt(entry.target.dataset.idx);
|
| 487 |
+
if(!isNaN(idx)) activate(idx);
|
| 488 |
+
}
|
| 489 |
+
});
|
| 490 |
+
}, {threshold: [0.6], root: feed});
|
| 491 |
+
|
| 492 |
+
slides.forEach(sl => io.observe(sl));
|
| 493 |
+
|
| 494 |
+
// Scroll event for manual detection (fallback)
|
| 495 |
+
let scrollTimer;
|
| 496 |
+
feed.addEventListener('scroll', ()=>{
|
| 497 |
+
_wcSManualScroll = true;
|
| 498 |
+
clearTimeout(manualTimer);
|
| 499 |
+
manualTimer = setTimeout(()=>{ _wcSManualScroll = false; }, 3000);
|
| 500 |
+
clearTimeout(scrollTimer);
|
| 501 |
+
scrollTimer = setTimeout(()=>{
|
| 502 |
+
const rect = feed.getBoundingClientRect();
|
| 503 |
+
const ctr = rect.top + rect.height / 2;
|
| 504 |
+
let best = -1, bestD = 1e9;
|
| 505 |
+
slides.forEach((sl,i)=>{
|
| 506 |
+
const r = sl.getBoundingClientRect();
|
| 507 |
+
const d = Math.abs(r.top + r.height/2 - ctr);
|
| 508 |
+
if(d < bestD){ bestD = d; best = i; }
|
| 509 |
+
});
|
| 510 |
+
if(best >= 0) activate(best);
|
| 511 |
+
}, 100);
|
| 512 |
+
}, {passive: true});
|
| 513 |
+
|
| 514 |
+
// Auto-advance every 12 seconds
|
| 515 |
+
if(_wcSOverlayTimer) clearInterval(_wcSOverlayTimer);
|
| 516 |
+
_wcSOverlayTimer = setInterval(()=>{
|
| 517 |
+
if(_wcSManualScroll) return;
|
| 518 |
+
if(!document.getElementById('wc-st-feed')){ clearInterval(_wcSOverlayTimer); _wcSOverlayTimer = null; return; }
|
| 519 |
+
const next = curIdx + 1;
|
| 520 |
+
if(next < slides.length){
|
| 521 |
+
slides[next].scrollIntoView({behavior:'smooth', block:'start'});
|
| 522 |
+
} else {
|
| 523 |
+
slides[0].scrollIntoView({behavior:'smooth', block:'start'});
|
| 524 |
+
}
|
| 525 |
+
}, 12000);
|
| 526 |
+
|
| 527 |
+
// Activate first after short delay
|
| 528 |
+
setTimeout(()=>{
|
| 529 |
+
if(slides.length > 0){ slides[0].scrollIntoView({behavior:'start'}); activate(0); }
|
| 530 |
+
}, 300);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
// ===== Interaction tracking =====
|
| 534 |
+
window.wcSTTrack = async function(videoId, type, idx){
|
| 535 |
+
try{
|
| 536 |
+
const r = await fetch('/api/short-action', {
|
| 537 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 538 |
+
body: JSON.stringify({id:videoId, action:type})
|
| 539 |
+
});
|
| 540 |
+
const j = await r.json();
|
| 541 |
+
if(j && j.stats){
|
| 542 |
+
if(idx !== undefined){
|
| 543 |
+
const vc = document.getElementById(`wcst-vc-${idx}`);
|
| 544 |
+
const lc = document.getElementById(`wcst-lc-${idx}`);
|
| 545 |
+
if(type==='view' && vc) vc.textContent = fmtNum(j.stats.views);
|
| 546 |
+
if(type==='like' && lc) lc.textContent = fmtNum(j.stats.likes);
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
}catch(e){ console.warn('Track error:', e); }
|
| 550 |
+
};
|
| 551 |
+
|
| 552 |
+
window.wcSTShare = function(videoId){
|
| 553 |
+
const url = 'https://youtube.com/shorts/' + videoId;
|
| 554 |
+
if(navigator.share){
|
| 555 |
+
navigator.share({title:'Short World Cup', url}).catch(()=>{});
|
| 556 |
+
} else {
|
| 557 |
+
navigator.clipboard.writeText(url).then(()=>alert('Đã sao chép link!')).catch(()=>{});
|
| 558 |
+
}
|
| 559 |
+
};
|
| 560 |
+
|
| 561 |
+
async function loadWCSFStats(shorts){
|
| 562 |
+
const ids = shorts.map(s=>s.id).filter(Boolean);
|
| 563 |
+
if(!ids.length) return;
|
| 564 |
+
// Batch in groups of 20
|
| 565 |
+
for(let i=0;i<ids.length;i+=20){
|
| 566 |
+
const batch = ids.slice(i,i+20).join(',');
|
| 567 |
+
try{
|
| 568 |
+
const r = await fetch('/api/short-stats?ids='+batch);
|
| 569 |
+
const j = await r.json();
|
| 570 |
+
if(j.stats){
|
| 571 |
+
shorts.forEach((s,idx)=>{
|
| 572 |
+
const st = j.stats[s.id];
|
| 573 |
+
if(st){
|
| 574 |
+
const vc = document.getElementById(`wcst-vc-${idx}`);
|
| 575 |
+
const lc = document.getElementById(`wcst-lc-${idx}`);
|
| 576 |
+
if(vc) vc.textContent = fmtNum(st.views);
|
| 577 |
+
if(lc) lc.textContent = fmtNum(st.likes);
|
| 578 |
+
}
|
| 579 |
+
});
|
| 580 |
+
}
|
| 581 |
+
}catch(e){}
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
function fmtNum(n){
|
| 586 |
+
if(!n) return '0';
|
| 587 |
+
if(n>=1e6) return (n/1e6).toFixed(1)+'M';
|
| 588 |
+
if(n>=1e3) return (n/1e3).toFixed(1)+'K';
|
| 589 |
+
return String(n);
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
// ===== Helpers =====
|
| 593 |
+
function escHtml(s){
|
| 594 |
+
return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
})();
|
|
@@ -1,5 +1,3 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
// === VNEWS — VTV LIVE + Inline Recorder v6 ===
|
| 4 |
// Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
|
| 5 |
// FIX v6: audio from captureStream (unmute during record), video.ended for crop, AI title generation
|
|
@@ -750,7 +748,7 @@
|
|
| 750 |
function activateMiniPlayer(){
|
| 751 |
if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
|
| 752 |
const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
|
| 753 |
-
if(_hls){const h=new Hls({enableWorker:
|
| 754 |
else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
|
| 755 |
const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
|
| 756 |
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(()=>{});
|
|
@@ -761,9 +759,8 @@
|
|
| 761 |
async function loadAllStreams(){
|
| 762 |
if(_loading) return; _loading=true;
|
| 763 |
const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
CHANNELS.forEach(ch=>{const t=document.getElementById('vtvt-'+ch.id); if(t){const data=d[ch.id]; if(STREAMS[ch.id]&&STREAMS[ch.id].length>0){t.classList.remove('off'); t.textContent=ch.name;}else if(data&&data.status==='unavailable'){t.style.opacity='0.35'; t.textContent=ch.name+' 🔒';}else{t.style.opacity='0.35'; t.textContent=ch.name+' ✕';}}});
|
| 767 |
_loading=false; _streamsLoaded=true;
|
| 768 |
}
|
| 769 |
|
|
@@ -772,7 +769,7 @@
|
|
| 772 |
let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
|
| 773 |
w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
|
| 774 |
'<div class="vtv-tabs">'+tabs+'</div>'+
|
| 775 |
-
'<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:
|
| 776 |
'<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>'+
|
| 777 |
'<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>';
|
| 778 |
return w;
|
|
@@ -796,79 +793,28 @@
|
|
| 796 |
|
| 797 |
window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
|
| 798 |
|
| 799 |
-
window._vtvPlay=function(chId){
|
| 800 |
const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
|
| 801 |
document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
|
| 802 |
const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
|
| 803 |
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');
|
| 804 |
-
video.style.display='
|
| 805 |
loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
|
| 806 |
if(_hls){_hls.destroy();_hls=null;}
|
| 807 |
const urls=STREAMS[chId]||[];
|
| 808 |
-
if(!urls.length
|
| 809 |
_tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
|
| 810 |
loadEpg(chId);
|
| 811 |
-
|
| 812 |
};
|
| 813 |
|
| 814 |
-
function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
|
| 815 |
if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
|
| 816 |
const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
|
| 817 |
if(typeof Hls!=='undefined'&&Hls.isSupported()){
|
| 818 |
-
const hls=new Hls({enableWorker:
|
| 819 |
-
|
| 820 |
-
hls.on(Hls.Events.
|
| 821 |
-
hls.on(Hls.Events.MEDIA_ATTACHED,()=>console.log('[VTV] MEDIA_ATTACHED'));
|
| 822 |
-
hls.on(Hls.Events.MANIFEST_LOADING,()=>console.log('[VTV] MANIFEST_LOADING',src));
|
| 823 |
-
hls.on(Hls.Events.MANIFEST_LOADED,()=>console.log('[VTV] MANIFEST_LOADED'));
|
| 824 |
-
hls.on(Hls.Events.MANIFEST_PARSED,(ev,data)=>{
|
| 825 |
-
const st=document.getElementById('vtv-status');
|
| 826 |
-
if(st) st.textContent='✅ Manifest OK | '+data.levels.length+' levels | '+(data.levels[0]?data.levels[0].width+'x'+data.levels[0].height:'?');
|
| 827 |
-
console.log('[VTV] MANIFEST_PARSED levels:',data.levels.length);
|
| 828 |
-
loadEl.style.display='none';video.style.opacity='1';video.style.display='block';setTimeout(()=>{
|
| 829 |
-
video.play().then(()=>{
|
| 830 |
-
if(st) st.textContent+=' | ▶ Playing';
|
| 831 |
-
console.log('[VTV] play OK');
|
| 832 |
-
}).catch(e=>{
|
| 833 |
-
if(st) st.textContent+=' | ⚠️ Play blocked: '+e;
|
| 834 |
-
console.log('[VTV] play:',e);
|
| 835 |
-
});
|
| 836 |
-
},100);
|
| 837 |
-
});
|
| 838 |
-
hls.on(Hls.Events.LEVEL_LOADING,(ev,l)=>console.log('[VTV] LEVEL_LOADING',l));
|
| 839 |
-
hls.on(Hls.Events.LEVEL_LOADED,(ev,l)=>console.log('[VTV] LEVEL_LOADED',l));
|
| 840 |
-
hls.on(Hls.Events.FRAG_LOADING,(ev,f)=>console.log('[VTV] FRAG_LOADING',f&&f.url&&f.url.substring(0,80)));
|
| 841 |
-
hls.on(Hls.Events.FRAG_LOADED,(ev,f)=>console.log('[VTV] FRAG_LOADED size:',f&&f.frag&&f.frag.data&&f.frag.data.length));
|
| 842 |
-
hls.on(Hls.Events.FRAG_BUFFERED,(ev,f)=>{
|
| 843 |
-
try{
|
| 844 |
-
const _n=f&&f.frag&&f.frag.sn;
|
| 845 |
-
const _st2=document.getElementById('vtv-status');
|
| 846 |
-
if(_st2&&_n) _st2.textContent='📦 Buffered frag #'+_n;
|
| 847 |
-
console.log('[VTV] FRAG_BUFFERED',f&&f.frag&&f.frag.url&&f.frag.url.substring(0,60));
|
| 848 |
-
}catch(e){}});
|
| 849 |
-
hls.on(Hls.Events.FRAG_PARSING_INIT_SEGMENT,()=>console.log('[VTV] FRAG_PARSING_INIT_SEGMENT'));
|
| 850 |
-
hls.on(Hls.Events.FRAG_PARSING_DATA,()=>console.log('[VTV] FRAG_PARSING_DATA'));
|
| 851 |
-
hls.on(Hls.Events.FRAG_PARSED,()=>console.log('[VTV] FRAG_PARSED'));
|
| 852 |
-
hls.on(Hls.Events.BUFFER_CREATED,()=>console.log('[VTV] BUFFER_CREATED'));
|
| 853 |
-
hls.on(Hls.Events.BUFFER_APPENDING,()=>console.log('[VTV] BUFFER_APPENDING'));
|
| 854 |
-
hls.on(Hls.Events.BUFFER_APPENDED,()=>console.log('[VTV] BUFFER_APPENDED'));
|
| 855 |
-
hls.on(Hls.Events.BUFFER_EOS,()=>console.log('[VTV] BUFFER_EOS'));
|
| 856 |
-
hls.on(Hls.Events.BUFFER_FLUSHING,()=>console.log('[VTV] BUFFER_FLUSHING'));
|
| 857 |
-
hls.on(Hls.Events.LEVEL_SWITCHING,(ev,d)=>console.log('[VTV] LEVEL_SWITCHING',d));
|
| 858 |
-
hls.on(Hls.Events.LEVEL_SWITCHED,(ev,d)=>console.log('[VTV] LEVEL_SWITCHED',d));
|
| 859 |
-
let rec=0; hls.on(Hls.Events.ERROR,(ev,data)=>{
|
| 860 |
-
const _st=document.getElementById('vtv-status');
|
| 861 |
-
if(_st) _st.textContent='❌ '+data.type+' | '+data.details+(data.fatal?' [FATAL]':'');
|
| 862 |
-
console.log('[VTV] ERROR type:',data.type,'details:',data.details,'fatal:',data.fatal,'reason:',data.reason,'url:',data.url&&data.url.substring(0,80));
|
| 863 |
-
if(data.fatal){
|
| 864 |
-
if(data.type===Hls.ErrorTypes.NETWORK_ERROR){
|
| 865 |
-
rec++;if(rec<=3){console.log('[VTV] retry',rec);setTimeout(()=>hls.startLoad(),2000);}
|
| 866 |
-
else{hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}
|
| 867 |
-
}else if(data.type===Hls.ErrorTypes.MEDIA_ERROR){
|
| 868 |
-
console.log('[VTV] MEDIA_ERROR, trying recover');
|
| 869 |
-
try{hls.recoverMediaError();}catch(e){console.log('[VTV] recover failed',e);hls.destroy();_hls=null;}
|
| 870 |
-
}else{console.log('[VTV] other fatal error');hls.destroy();_hls=null;_tryPlay(video,urls,idx+1,name,loadEl,errEl,errMsg);}
|
| 871 |
-
}});
|
| 872 |
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 873 |
video.src=src;
|
| 874 |
video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
|
|
@@ -876,44 +822,15 @@
|
|
| 876 |
}else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
|
| 877 |
}
|
| 878 |
|
| 879 |
-
}catch(e){console.log('[VTV] _tryPlay error:',e);}
|
| 880 |
-
|
| 881 |
// ===== NAV HOOKS =====
|
| 882 |
const _origShowView=window.showView;
|
| 883 |
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);};
|
| 884 |
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);};}
|
| 885 |
-
const _origSwitchCat=window.switchCat;
|
| 886 |
-
if(_origSwitchCat){
|
| 887 |
-
window.switchCat=function(id){
|
| 888 |
-
if(id==='home'){if(_miniActive)closeMiniPlayer();}
|
| 889 |
-
else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();
|
| 890 |
-
const r=_origSwitchCat.apply(this,arguments);
|
| 891 |
-
// Pin VTV block whenever home tab becomes active
|
| 892 |
-
if(id==='home'){setTimeout(()=>pinBlock(),100);}
|
| 893 |
-
return r;
|
| 894 |
-
};
|
| 895 |
-
}
|
| 896 |
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
if(_blockInserted) return;
|
| 902 |
-
_pinAttempts++;
|
| 903 |
-
if(_pinAttempts>30) return; // give up after ~15s
|
| 904 |
-
const h=document.getElementById('view-home');
|
| 905 |
-
if(h && h.children.length>0 && document.getElementById('vtv-block')){
|
| 906 |
-
_blockInserted=true; return; // already pinned
|
| 907 |
-
}
|
| 908 |
-
if(h && h.children.length>0){
|
| 909 |
-
pinBlock();
|
| 910 |
-
} else {
|
| 911 |
-
setTimeout(_tryPin, 500);
|
| 912 |
-
}
|
| 913 |
-
}
|
| 914 |
-
if(document.readyState==='loading'){
|
| 915 |
-
document.addEventListener('DOMContentLoaded', _tryPin);
|
| 916 |
-
} else {
|
| 917 |
-
_tryPin();
|
| 918 |
}
|
| 919 |
})();
|
|
|
|
|
|
|
|
|
|
| 1 |
// === VNEWS — VTV LIVE + Inline Recorder v6 ===
|
| 2 |
// Features: PiP, mini-player, INLINE RECORDER with ratio crop + AI title + Short AI upload
|
| 3 |
// FIX v6: audio from captureStream (unmute during record), video.ended for crop, AI title generation
|
|
|
|
| 748 |
function activateMiniPlayer(){
|
| 749 |
if(!_currentCh) return; createMiniPlayer(); _miniActive=true;
|
| 750 |
const mini=document.getElementById('vtv-mini'); const mv=document.getElementById('vtv-mini-vid'); const v=document.getElementById('vtv-player');
|
| 751 |
+
if(_hls){const h=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:10}); h.loadSource(STREAMS[_currentCh][0]); h.attachMedia(mv); mv.play().catch(()=>{});}
|
| 752 |
else if(v&&v.src){mv.src=v.src; mv.play().catch(()=>{});}
|
| 753 |
const ch=CHANNELS.find(c=>c.id===_currentCh); if(ch) document.getElementById('vtv-mini-ch').textContent=ch.name;
|
| 754 |
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(()=>{});
|
|
|
|
| 759 |
async function loadAllStreams(){
|
| 760 |
if(_loading) return; _loading=true;
|
| 761 |
const el=document.getElementById('vtv-load'); if(el) el.innerHTML='<div class="vtv-spinner"></div>Đang tải kênh...';
|
| 762 |
+
try{const r=await fetch('/api/vtv/streams',{signal:AbortSignal.timeout(10000)}); if(r.ok){const d=await r.json(); CHANNELS.forEach(ch=>{const i=d[ch.id]; if(i&&i.stream_url){let u=i.stream_url; if(NEEDS_PROXY.test(u)) u='/api/proxy/m3u8/vtv?url='+encodeURIComponent(u); STREAMS[ch.id]=[u];} else STREAMS[ch.id]=[];});}}catch(e){}
|
| 763 |
+
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+' ✕';}}});
|
|
|
|
| 764 |
_loading=false; _streamsLoaded=true;
|
| 765 |
}
|
| 766 |
|
|
|
|
| 769 |
let tabs=''; CHANNELS.forEach(ch=>{tabs+='<button class="vtv-tab off" id="vtvt-'+ch.id+'" onclick="window._vtvPlay(\''+ch.id+'\')">'+ch.name+'</button>';});
|
| 770 |
w.innerHTML='<div class="vtv-head"><span class="vtv-title">📺 VTV Trực Tuyến</span><span class="vtv-badge">● LIVE</span></div>'+
|
| 771 |
'<div class="vtv-tabs">'+tabs+'</div>'+
|
| 772 |
+
'<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>'+
|
| 773 |
'<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>'+
|
| 774 |
'<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>';
|
| 775 |
return w;
|
|
|
|
| 793 |
|
| 794 |
window._vtvRetry=function(){if(_currentCh)window._vtvPlay(_currentCh);};
|
| 795 |
|
| 796 |
+
window._vtvPlay=function(chId){
|
| 797 |
const ch=CHANNELS.find(c=>c.id===chId); if(!ch) return; _currentCh=chId;
|
| 798 |
document.querySelectorAll('.vtv-tab').forEach(t=>t.classList.remove('on'));
|
| 799 |
const tab=document.getElementById('vtvt-'+chId); if(tab) tab.classList.add('on');
|
| 800 |
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');
|
| 801 |
+
video.style.display='none'; errEl.style.display='none'; loadEl.style.display='flex';
|
| 802 |
loadEl.innerHTML='<div class="vtv-spinner"></div>Đang kết nối '+ch.name+'...';
|
| 803 |
if(_hls){_hls.destroy();_hls=null;}
|
| 804 |
const urls=STREAMS[chId]||[];
|
| 805 |
+
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;}
|
| 806 |
_tryPlay(video,urls,0,ch.name,loadEl,errEl,errMsg);
|
| 807 |
loadEpg(chId);
|
| 808 |
+
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(()=>{});}
|
| 809 |
};
|
| 810 |
|
| 811 |
+
function _tryPlay(video,urls,idx,name,loadEl,errEl,errMsg){
|
| 812 |
if(idx>=urls.length){loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent=name+': Tất cả nguồn lỗi.';return;}
|
| 813 |
const src=urls[idx]; loadEl.innerHTML='<div class="vtv-spinner"></div>Kết nối '+name+' ('+(idx+1)+'/'+urls.length+')...';
|
| 814 |
if(typeof Hls!=='undefined'&&Hls.isSupported()){
|
| 815 |
+
const hls=new Hls({enableWorker:true,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:20}); _hls=hls; hls.loadSource(src); hls.attachMedia(video);
|
| 816 |
+
hls.on(Hls.Events.MANIFEST_PARSED,()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';});
|
| 817 |
+
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);}}});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
}else if(video.canPlayType('application/vnd.apple.mpegurl')){
|
| 819 |
video.src=src;
|
| 820 |
video.addEventListener('loadedmetadata',()=>{video.play().catch(()=>{});loadEl.style.display='none';video.style.display='block';},{once:true});
|
|
|
|
| 822 |
}else{loadEl.style.display='none';errEl.style.display='flex';errMsg.textContent='Không hỗ trợ HLS';}
|
| 823 |
}
|
| 824 |
|
|
|
|
|
|
|
| 825 |
// ===== NAV HOOKS =====
|
| 826 |
const _origShowView=window.showView;
|
| 827 |
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);};
|
| 828 |
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);};}
|
| 829 |
+
const _origSwitchCat=window.switchCat; if(_origSwitchCat){window.switchCat=function(id){if(id==='home'){if(_miniActive)closeMiniPlayer();}else if(_currentCh&&STREAMS[_currentCh]&&STREAMS[_currentCh].length>0&&!_miniActive)activateMiniPlayer();return _origSwitchCat.apply(this,arguments);};}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
|
| 831 |
+
const _origLoadHome=window.loadHome;
|
| 832 |
+
if(_origLoadHome&&!_origLoadHome.__vtvWrapped){
|
| 833 |
+
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;};
|
| 834 |
+
window.loadHome.__vtvWrapped=true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
}
|
| 836 |
})();
|
|
@@ -1,19 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
-
|
| 4 |
-
1. VTVGo CDN (vtvgolive-failover.vtvdigital.vn) — PRIMARY, works for vtv1-vtv9
|
| 5 |
-
2. fptplay CDN — FALLBACK for vtv6 only (others blocked)
|
| 6 |
-
3. xemtv.net PHP — DEAD (all 403 from Cloudflare)
|
| 7 |
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 8 |
"""
|
| 9 |
import re, time, threading
|
| 10 |
import requests
|
| 11 |
from fastapi import APIRouter, Query
|
| 12 |
-
from fastapi.responses import JSONResponse, Response
|
| 13 |
from bs4 import BeautifulSoup
|
| 14 |
from datetime import datetime, timedelta, timezone
|
| 15 |
-
import warnings
|
| 16 |
-
warnings.filterwarnings("ignore")
|
| 17 |
|
| 18 |
VN_TZ = timezone(timedelta(hours=7))
|
| 19 |
|
|
@@ -22,10 +17,23 @@ router = APIRouter()
|
|
| 22 |
UA = {
|
| 23 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 24 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 25 |
-
"Referer": "https://
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
-
# ===== CHANNEL DEFINITIONS =====
|
| 29 |
CHANNEL_NAMES = {
|
| 30 |
"vtv1": "VTV1",
|
| 31 |
"vtv2": "VTV2",
|
|
@@ -36,37 +44,36 @@ CHANNEL_NAMES = {
|
|
| 36 |
"vtv7": "VTV7",
|
| 37 |
"vtv8": "VTV8",
|
| 38 |
"vtv9": "VTV9",
|
| 39 |
-
"vtv10": "
|
| 40 |
"vtvprime": "VTVPrime",
|
| 41 |
}
|
| 42 |
|
| 43 |
-
|
| 44 |
-
# Confirmed working (2026-06-16): vtv1, vtv2, vtv4, vtv5, vtv6, vtv7, vtv8
|
| 45 |
-
# Not available: vtv3 (404), vtv9 (404), vtv10 (404), vtvprime (404)
|
| 46 |
-
VTVGO_CDN = {
|
| 47 |
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 48 |
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
|
|
|
| 49 |
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 50 |
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
| 51 |
-
"vtv6": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv6-manifest.m3u8",
|
| 52 |
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 53 |
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
FPTPLAY_URLS = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
"vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
-
# ===== DEAD: xemtv.net (all 403 from Cloudflare, kept for reference) =====
|
| 63 |
-
# XEMTV_PHP_ENDPOINTS = { ... } # All dead as of 2026-06
|
| 64 |
-
|
| 65 |
-
# Channels with no public stream available on any CDN
|
| 66 |
-
# vtv3, vtv9: VTVGo CDN returned 404 as of 2026-06-16
|
| 67 |
-
# vtv10, vtvprime: never had public streams
|
| 68 |
-
UNAVAILABLE_CHANNELS = {"vtv3", "vtv9", "vtv10", "vtvprime"}
|
| 69 |
-
|
| 70 |
_vtv_cache = {}
|
| 71 |
_vtv_lock = threading.Lock()
|
| 72 |
_CACHE_TTL = 180
|
|
@@ -81,32 +88,50 @@ def _set_cache(key, data):
|
|
| 81 |
with _vtv_lock:
|
| 82 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 83 |
|
| 84 |
-
def
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
except:
|
| 91 |
pass
|
| 92 |
return None
|
| 93 |
|
| 94 |
-
def
|
| 95 |
-
|
|
|
|
|
|
|
| 96 |
try:
|
| 97 |
headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"}
|
| 98 |
-
r = requests.get(url, headers=headers, timeout=
|
| 99 |
-
if r.status_code == 200
|
| 100 |
return url
|
| 101 |
except:
|
| 102 |
pass
|
| 103 |
return None
|
| 104 |
|
| 105 |
def fetch_vtv_stream(channel_id):
|
| 106 |
-
"""Fetch stream URL for a VTV channel. Tries VTVGo CDN first, then fptplay fallback."""
|
| 107 |
channel_id = channel_id.lower().strip()
|
| 108 |
-
|
| 109 |
-
# Normalize name
|
| 110 |
name_map = {
|
| 111 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 112 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
|
|
@@ -115,63 +140,65 @@ def fetch_vtv_stream(channel_id):
|
|
| 115 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 116 |
}
|
| 117 |
channel_id = name_map.get(channel_id, channel_id)
|
| 118 |
-
|
| 119 |
cached = _cached(channel_id)
|
| 120 |
if cached is not None:
|
| 121 |
return cached
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
_set_cache(channel_id, None)
|
| 126 |
return None
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
_set_cache(channel_id, result)
|
| 133 |
-
return result
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
_set_cache(channel_id, None)
|
| 143 |
return None
|
| 144 |
|
| 145 |
@router.get("/api/vtv/streams")
|
| 146 |
def api_vtv_streams():
|
| 147 |
-
"""Get all VTV channel streams. Returns status for each channel."""
|
| 148 |
result = {}
|
| 149 |
-
for ch_id
|
| 150 |
-
stream_url =
|
| 151 |
-
|
| 152 |
-
stream_url = fetch_vtv_stream(ch_id)
|
| 153 |
-
|
| 154 |
-
if stream_url:
|
| 155 |
-
result[ch_id] = {"name": name, "stream_url": stream_url, "status": "ok"}
|
| 156 |
-
elif ch_id in UNAVAILABLE_CHANNELS:
|
| 157 |
-
result[ch_id] = {"name": name, "stream_url": None, "status": "unavailable"}
|
| 158 |
-
else:
|
| 159 |
-
result[ch_id] = {"name": name, "stream_url": None, "status": "offline"}
|
| 160 |
return JSONResponse(result)
|
| 161 |
|
| 162 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 163 |
def api_vtv_stream(channel_id: str):
|
| 164 |
-
"""Get stream URL for a specific VTV channel."""
|
| 165 |
stream_url = fetch_vtv_stream(channel_id)
|
| 166 |
if stream_url:
|
| 167 |
return JSONResponse({"stream_url": stream_url, "status": "ok"})
|
| 168 |
-
if channel_id.lower().strip() in UNAVAILABLE_CHANNELS:
|
| 169 |
-
return JSONResponse({"error": "channel not available (premium/geo-restricted)", "status": "unavailable"}, status_code=404)
|
| 170 |
return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
|
| 171 |
|
| 172 |
@router.get("/api/proxy/page")
|
| 173 |
def proxy_page(url: str = Query(...)):
|
| 174 |
-
"""Proxy HTML pages (CORS workaround)."""
|
| 175 |
try:
|
| 176 |
headers = {**UA}
|
| 177 |
if "xemtv.net" in url:
|
|
@@ -185,22 +212,20 @@ def proxy_page(url: str = Query(...)):
|
|
| 185 |
|
| 186 |
@router.get("/api/proxy/m3u8/vtv")
|
| 187 |
def proxy_vtv_m3u8(url: str = Query(...)):
|
| 188 |
-
"""Proxy m3u8 playlist. Rewrites .ts segments and .m3u8 variants through our proxy chain.
|
| 189 |
-
Uses fully-qualified absolute URLs so HLS.js resolves them correctly."""
|
| 190 |
try:
|
| 191 |
-
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
|
|
|
|
|
|
| 195 |
if r.status_code != 200:
|
| 196 |
return Response(status_code=502, content="upstream error")
|
| 197 |
content = r.text
|
| 198 |
lines = content.split('\n')
|
| 199 |
rewritten = []
|
| 200 |
base_url = url.rsplit('/', 1)[0] + '/'
|
| 201 |
-
# Build the base URL for this proxy endpoint
|
| 202 |
-
proxy_base = "/api/proxy/m3u8/vtv?url="
|
| 203 |
-
seg_proxy_base = "/api/proxy/seg/vtv?url="
|
| 204 |
for line in lines:
|
| 205 |
line = line.strip()
|
| 206 |
if not line or line.startswith('#'):
|
|
@@ -209,156 +234,29 @@ def proxy_vtv_m3u8(url: str = Query(...)):
|
|
| 209 |
seg_url = line
|
| 210 |
if not seg_url.startswith('http'):
|
| 211 |
seg_url = base_url + seg_url
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
# Variant playlist → proxy through this same m3u8 endpoint (full URL)
|
| 215 |
-
rewritten.append(proxy_base + requests.utils.quote(seg_url, safe=""))
|
| 216 |
-
else:
|
| 217 |
-
# .ts segment → proxy through segment endpoint (full URL)
|
| 218 |
-
rewritten.append(seg_proxy_base + requests.utils.quote(seg_url, safe=""))
|
| 219 |
-
return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache, no-store", "Access-Control-Allow-Headers": "*"})
|
| 220 |
except Exception as e:
|
| 221 |
return Response(status_code=502, content="proxy error: " + str(e))
|
| 222 |
|
| 223 |
@router.get("/api/proxy/seg/vtv")
|
| 224 |
def proxy_vtv_segment(url: str = Query(...)):
|
| 225 |
-
"""Proxy video segments (.ts files) and other binary content."""
|
| 226 |
try:
|
| 227 |
-
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
| 231 |
if r.status_code != 200:
|
| 232 |
return Response(status_code=502, content="upstream error")
|
| 233 |
data = r.content
|
| 234 |
-
|
| 235 |
-
content_type = "video/mp2t" # default for .ts
|
| 236 |
-
if url.endswith('.m3u8') or url.endswith('.m3u'):
|
| 237 |
-
content_type = "application/vnd.apple.mpegurl"
|
| 238 |
-
elif url.endswith('.mp4'):
|
| 239 |
-
content_type = "video/mp4"
|
| 240 |
-
elif url.endswith('.aac'):
|
| 241 |
-
content_type = "audio/aac"
|
| 242 |
-
elif url.endswith('.webm'):
|
| 243 |
-
content_type = "video/webm"
|
| 244 |
-
# Fix: some TS segments start with PNG header (188 bytes offset)
|
| 245 |
-
if content_type == "video/mp2t" and len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
| 246 |
data = data[188:]
|
| 247 |
-
return Response(content=data, media_type=
|
| 248 |
except:
|
| 249 |
return Response(status_code=502, content="proxy error")
|
| 250 |
|
| 251 |
|
| 252 |
-
@router.get("/api/proxy/stream/vtv")
|
| 253 |
-
def proxy_vtv_stream(channel_id: str = Query(...)):
|
| 254 |
-
"""Unified streaming proxy: fetches VTV manifest, rewrites ALL segment URLs
|
| 255 |
-
to go through this same endpoint (self-referencing).
|
| 256 |
-
|
| 257 |
-
HLS.js loads ONE URL and all segments are automatically proxied.
|
| 258 |
-
This avoids the double-hop latency issue.
|
| 259 |
-
"""
|
| 260 |
-
# Resolve channel to VTVGo manifest URL
|
| 261 |
-
ch = channel_id.lower().strip()
|
| 262 |
-
name_map = {
|
| 263 |
-
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 264 |
-
'vietnam_vtv1': 'vtv1', 'vietnam_vtv2': 'vtv2', 'vietnam_vtv3': 'vtv3',
|
| 265 |
-
}
|
| 266 |
-
ch = name_map.get(ch, ch)
|
| 267 |
-
|
| 268 |
-
manifest_url = VTVGO_CDN.get(ch)
|
| 269 |
-
if not manifest_url:
|
| 270 |
-
return JSONResponse({"error": "channel not found"}, status_code=404)
|
| 271 |
-
|
| 272 |
-
# Stream the variant through ourselves
|
| 273 |
-
# Return a variant m3u8 with segments pointing to this same endpoint
|
| 274 |
-
try:
|
| 275 |
-
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://vtv.vn/"}
|
| 276 |
-
r = requests.get(manifest_url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 277 |
-
if r.status_code != 200:
|
| 278 |
-
return Response(status_code=502, content="upstream error")
|
| 279 |
-
|
| 280 |
-
content = r.text
|
| 281 |
-
lines = content.split('\n')
|
| 282 |
-
|
| 283 |
-
# For each variant in the master, return its segment list with self-referencing URLs
|
| 284 |
-
rewritten = []
|
| 285 |
-
base_url = manifest_url.rsplit('/', 1)[0] + '/'
|
| 286 |
-
|
| 287 |
-
for line in lines:
|
| 288 |
-
line_stripped = line.strip()
|
| 289 |
-
if not line_stripped or line_stripped.startswith('#EXTM3U') or line_stripped.startswith('#EXT-X-VERSION'):
|
| 290 |
-
rewritten.append(line_stripped)
|
| 291 |
-
elif line_stripped.startswith('#EXT-X-STREAM-INF'):
|
| 292 |
-
rewritten.append(line_stripped)
|
| 293 |
-
elif line_stripped.startswith('#'):
|
| 294 |
-
rewritten.append(line_stripped)
|
| 295 |
-
else:
|
| 296 |
-
# This could be a variant .m3u8 URL (from master) or segment (from variant)
|
| 297 |
-
seg_url = line_stripped
|
| 298 |
-
if not seg_url.startswith('http'):
|
| 299 |
-
seg_url = base_url + seg_url
|
| 300 |
-
|
| 301 |
-
if seg_url.endswith('.m3u8'):
|
| 302 |
-
# This is a variant playlist URL from master manifest
|
| 303 |
-
# Return a rewritten variant with self-referencing segment URLs
|
| 304 |
-
rewritten.append("/api/proxy/stream/vtv/" + ch + "/variant?url=" + requests.utils.quote(seg_url, safe=""))
|
| 305 |
-
else:
|
| 306 |
-
# TS segment - proxy through seg endpoint
|
| 307 |
-
rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 308 |
-
|
| 309 |
-
return Response(
|
| 310 |
-
content='\n'.join(rewritten).encode("utf-8"),
|
| 311 |
-
media_type="application/vnd.apple.mpegurl",
|
| 312 |
-
headers={
|
| 313 |
-
"Access-Control-Allow-Origin": "*",
|
| 314 |
-
"Cache-Control": "no-cache, no-store",
|
| 315 |
-
"Access-Control-Allow-Headers": "*"
|
| 316 |
-
}
|
| 317 |
-
)
|
| 318 |
-
except Exception as e:
|
| 319 |
-
return Response(status_code=502, content="proxy error: " + str(e))
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
@router.get("/api/proxy/stream/vtv/{channel_id}/variant")
|
| 323 |
-
def proxy_vtv_variant(channel_id: str, url: str = Query(...)):
|
| 324 |
-
"""Fetch a variant m3u8 and rewrite its .ts segments to go through seg proxy."""
|
| 325 |
-
try:
|
| 326 |
-
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*", "Referer": "https://vtv.vn/"}
|
| 327 |
-
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True, verify=False)
|
| 328 |
-
if r.status_code != 200:
|
| 329 |
-
return Response(status_code=502, content="upstream error")
|
| 330 |
-
|
| 331 |
-
content = r.text
|
| 332 |
-
lines = content.split('\n')
|
| 333 |
-
rewritten = []
|
| 334 |
-
base_url = url.rsplit('/', 1)[0] + '/'
|
| 335 |
-
|
| 336 |
-
for line in lines:
|
| 337 |
-
line_stripped = line.strip()
|
| 338 |
-
if not line_stripped or line_stripped.startswith('#'):
|
| 339 |
-
rewritten.append(line_stripped)
|
| 340 |
-
else:
|
| 341 |
-
seg_url = line_stripped
|
| 342 |
-
if not seg_url.startswith('http'):
|
| 343 |
-
seg_url = base_url + seg_url
|
| 344 |
-
if seg_url.endswith('.ts'):
|
| 345 |
-
rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 346 |
-
else:
|
| 347 |
-
rewritten.append(seg_url)
|
| 348 |
-
|
| 349 |
-
return Response(
|
| 350 |
-
content='\n'.join(rewritten).encode("utf-8"),
|
| 351 |
-
media_type="application/vnd.apple.mpegurl",
|
| 352 |
-
headers={
|
| 353 |
-
"Access-Control-Allow-Origin": "*",
|
| 354 |
-
"Cache-Control": "no-cache, no-store",
|
| 355 |
-
"Access-Control-Allow-Headers": "*"
|
| 356 |
-
}
|
| 357 |
-
)
|
| 358 |
-
except Exception as e:
|
| 359 |
-
return Response(status_code=502, content="proxy error: " + str(e))
|
| 360 |
-
|
| 361 |
-
|
| 362 |
# ===== EPG from vtv.vn/lich-phat-song.htm =====
|
| 363 |
|
| 364 |
_epg_cache = {}
|
|
@@ -564,78 +462,4 @@ def api_vtv_epg_refresh():
|
|
| 564 |
"status": "refreshed",
|
| 565 |
"channels": len(epg_data),
|
| 566 |
"total_programmes": sum(len(v) for v in epg_data),
|
| 567 |
-
})
|
| 568 |
-
|
| 569 |
-
@router.get("/api/vtv/test", response_class=HTMLResponse)
|
| 570 |
-
def vtv_test_page():
|
| 571 |
-
"""VTV stream test page for debugging."""
|
| 572 |
-
return """
|
| 573 |
-
<!DOCTYPE html>
|
| 574 |
-
<html>
|
| 575 |
-
<head>
|
| 576 |
-
<meta charset="utf-8">
|
| 577 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 578 |
-
<title>VTV Test</title>
|
| 579 |
-
<style>
|
| 580 |
-
body{background:#111;color:#eee;font-family:monospace;padding:16px;margin:0}
|
| 581 |
-
h2{color:#5cb87a;font-size:16px;margin:0 0 10px}
|
| 582 |
-
video{width:100%;max-width:640px;background:#000;margin:10px 0;border:2px solid #333}
|
| 583 |
-
#log{background:#1a1a1a;border:1px solid #333;padding:10px;max-height:250px;overflow-y:auto;white-space:pre-wrap;font-size:11px;line-height:1.5;margin-top:10px}
|
| 584 |
-
.ok{color:#5cb87a}.err{color:#e74c3c}.warn{color:#f0c040}.info{color:#888}
|
| 585 |
-
button{background:#2d8659;border:0;color:#fff;padding:8px 16px;border-radius:8px;cursor:pointer;margin:4px;font-size:12px}
|
| 586 |
-
select{background:#222;color:#eee;border:1px solid #444;padding:6px;border-radius:6px;font-size:12px}
|
| 587 |
-
</style>
|
| 588 |
-
</head>
|
| 589 |
-
<body>
|
| 590 |
-
<h2>📺 VTV Stream Test</h2>
|
| 591 |
-
<div>
|
| 592 |
-
<select id="ch">
|
| 593 |
-
<option value="vtv1">VTV1</option><option value="vtv2">VTV2</option>
|
| 594 |
-
<option value="vtv4">VTV4</option><option value="vtv5">VTV5</option>
|
| 595 |
-
<option value="vtv6" selected>VTV6</option><option value="vtv7">VTV7</option>
|
| 596 |
-
<option value="vtv8">VTV8</option>
|
| 597 |
-
</select>
|
| 598 |
-
<button onclick="playHLS()">▶ Play (HLS.js)</button>
|
| 599 |
-
<button onclick="playNative()">▶ Play (Native)</button>
|
| 600 |
-
</div>
|
| 601 |
-
<video id="v" controls autoplay muted playsinline></video>
|
| 602 |
-
<div id="log"></div>
|
| 603 |
-
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js"></script>
|
| 604 |
-
<script>
|
| 605 |
-
const LOG=(msg,t)=>{const e=document.getElementById('log');e.innerHTML+='<span class="'+(t||'info')+'">['+new Date().toISOString().substr(11,12)+'] '+msg+'\\n</span>';e.scrollTop=e.scrollHeight;};
|
| 606 |
-
const V={"vtv1":"/api/proxy/stream/vtv?channel_id=vtv1","vtv2":"/api/proxy/stream/vtv?channel_id=vtv2","vtv4":"/api/proxy/stream/vtv?channel_id=vtv4","vtv5":"/api/proxy/stream/vtv?channel_id=vtv5","vtv6":"/api/proxy/stream/vtv?channel_id=vtv6","vtv7":"/api/proxy/stream/vtv?channel_id=vtv7","vtv8":"/api/proxy/stream/vtv?channel_id=vtv8"};
|
| 607 |
-
const video=document.getElementById('v');
|
| 608 |
-
let hls=null;
|
| 609 |
-
LOG('Ready. HLS.js '+(Hls.version||'unknown')+' | supported: '+Hls.isSupported(),'ok');
|
| 610 |
-
function playHLS(){
|
| 611 |
-
const ch=document.getElementById('ch').value,url=V[ch];
|
| 612 |
-
LOG('\\n--- HLS.js: '+ch+' ---','info');
|
| 613 |
-
if(hls){hls.destroy();hls=null;}
|
| 614 |
-
video.pause();video.src='';
|
| 615 |
-
if(!Hls.isSupported()){LOG('HLS.js not supported, trying native','warn');playNative();return;}
|
| 616 |
-
hls=new Hls({enableWorker:false,lowLatencyMode:true,startLevel:-1,capLevelToPlayerSize:true,maxBufferLength:6});
|
| 617 |
-
hls.on(Hls.Events.MANIFEST_PARSED,(ev,d)=>{LOG('✅ MANIFEST '+d.levels.length+' levels '+(d.levels[0]?d.levels[0].width+'x'+d.levels[0].height:''),'ok');video.play().then(()=>LOG('▶ Playing','ok')).catch(e=>LOG('Play: '+e,'warn'));});
|
| 618 |
-
hls.on(Hls.Events.FRAG_BUFFERED,(ev,d)=>{LOG('📦 Buf #'+d.frag.sn+' video.w='+video.videoWidth,'ok');});
|
| 619 |
-
hls.on(Hls.Events.ERROR,(ev,d)=>{LOG('❌ '+d.type+' '+d.details+' fatal='+d.fatal,'err');if(d.fatal){hls.destroy();hls=null;}});
|
| 620 |
-
hls.loadSource(url);
|
| 621 |
-
hls.attachMedia(video);
|
| 622 |
-
let c=0;const iv=setInterval(()=>{c++;if(c>15)clearInterval(iv);
|
| 623 |
-
LOG('State '+c+': readyState='+video.readyState+' videoWidth='+video.videoWidth+' paused='+video.paused,'info');
|
| 624 |
-
if(video.videoWidth>0){LOG('🎉 VIDEO HAS FRAMES!','ok');clearInterval(iv);}
|
| 625 |
-
},1000);
|
| 626 |
-
}
|
| 627 |
-
function playNative(){
|
| 628 |
-
const ch=document.getElementById('ch').value,url=V[ch];
|
| 629 |
-
LOG('\\n--- Native: '+ch+' ---','info');
|
| 630 |
-
if(hls){hls.destroy();hls=null;}
|
| 631 |
-
video.pause();video.src=url;
|
| 632 |
-
video.play().then(()=>LOG('▶ Native playing','ok')).catch(e=>LOG('Native play: '+e,'warn'));
|
| 633 |
-
let c=0;const iv=setInterval(()=>{c++;if(c>15)clearInterval(iv);
|
| 634 |
-
LOG('State '+c+': readyState='+video.readyState+' videoWidth='+video.videoWidth,'info');
|
| 635 |
-
if(video.videoWidth>0){LOG('🎉 VIDEO HAS FRAMES!','ok');clearInterval(iv);}
|
| 636 |
-
},1000);
|
| 637 |
-
}
|
| 638 |
-
</script>
|
| 639 |
-
</body>
|
| 640 |
-
</html>
|
| 641 |
-
"""
|
|
|
|
| 1 |
"""
|
| 2 |
VTV Channels API - Backend endpoints for VTV1-VTV10 + VTVPrime
|
| 3 |
+
Fetches stream URLs from hd.xemtv.net PHP endpoints
|
|
|
|
|
|
|
|
|
|
| 4 |
EPG data scraped from https://vtv.vn/lich-phat-song.htm
|
| 5 |
"""
|
| 6 |
import re, time, threading
|
| 7 |
import requests
|
| 8 |
from fastapi import APIRouter, Query
|
| 9 |
+
from fastapi.responses import JSONResponse, Response
|
| 10 |
from bs4 import BeautifulSoup
|
| 11 |
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
| 12 |
|
| 13 |
VN_TZ = timezone(timedelta(hours=7))
|
| 14 |
|
|
|
|
| 17 |
UA = {
|
| 18 |
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
| 19 |
"Accept-Language": "vi-VN,vi;q=0.9",
|
| 20 |
+
"Referer": "https://hd.xemtv.net/",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
XEMTV_PHP_ENDPOINTS = {
|
| 24 |
+
"vtv1": "https://hd.xemtv.net/kenh/vtv1.php",
|
| 25 |
+
"vtv2": "https://hd.xemtv.net/kenh/vtv2.php",
|
| 26 |
+
"vtv3": "https://hd.xemtv.net/kenh/vtv3.php",
|
| 27 |
+
"vtv4": "https://hd.xemtv.net/kenh/vtv4.php",
|
| 28 |
+
"vtv5": "https://hd.xemtv.net/kenh/vtv5.php",
|
| 29 |
+
"vtv6": "https://hd.xemtv.net/kenh/vtv6.php",
|
| 30 |
+
"vtv7": "https://hd.xemtv.net/kenh/vtv7.php",
|
| 31 |
+
"vtv8": "https://hd.xemtv.net/kenh/vtv8.php",
|
| 32 |
+
"vtv9": "https://hd.xemtv.net/kenh/vtv9.php",
|
| 33 |
+
"vtv10": "https://hd.xemtv.net/kenh/vtv10.php",
|
| 34 |
+
"vtvprime": "https://hd.xemtv.net/kenh/vtvprime.php",
|
| 35 |
}
|
| 36 |
|
|
|
|
| 37 |
CHANNEL_NAMES = {
|
| 38 |
"vtv1": "VTV1",
|
| 39 |
"vtv2": "VTV2",
|
|
|
|
| 44 |
"vtv7": "VTV7",
|
| 45 |
"vtv8": "VTV8",
|
| 46 |
"vtv9": "VTV9",
|
| 47 |
+
"vtv10": "VTV10",
|
| 48 |
"vtvprime": "VTVPrime",
|
| 49 |
}
|
| 50 |
|
| 51 |
+
VTVGO_FAILOVER = {
|
|
|
|
|
|
|
|
|
|
| 52 |
"vtv1": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv1-manifest.m3u8",
|
| 53 |
"vtv2": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv2-manifest.m3u8",
|
| 54 |
+
"vtv3": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv3-manifest.m3u8",
|
| 55 |
"vtv4": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv4-manifest.m3u8",
|
| 56 |
"vtv5": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv5-manifest.m3u8",
|
|
|
|
| 57 |
"vtv7": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv7-manifest.m3u8",
|
| 58 |
"vtv8": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv8-manifest.m3u8",
|
| 59 |
+
"vtv9": "https://vtvgolive-failover.vtvdigital.vn/vtvgo/vtv9-manifest.m3u8",
|
| 60 |
}
|
| 61 |
|
| 62 |
+
XEMTV_ONLY_CHANNELS = {"vtv6", "vtv10"}
|
| 63 |
+
|
| 64 |
FPTPLAY_URLS = {
|
| 65 |
+
"vtv1": "https://live.fptplay53.net/fnxch2/vtv1hd_abr.smil/chunklist.m3u8",
|
| 66 |
+
"vtv2": "https://live.fptplay53.net/fnxch2/vtv2hd_abr.smil/chunklist.m3u8",
|
| 67 |
+
"vtv3": "https://live.fptplay53.net/fnxch2/vtv3hd_abr.smil/chunklist.m3u8",
|
| 68 |
+
"vtv4": "https://live.fptplay53.net/fnxch2/vtv4hd_abr.smil/chunklist.m3u8",
|
| 69 |
+
"vtv5": "https://live-a.fptplay53.net/live/media/VTV5HD/live_hls_avc/index.m3u8",
|
| 70 |
"vtv6": "https://live-a.fptplay53.net/live/media/vtv6/live247-hls-avc/vtv6-avc1_5600000=10000-mp4a_131600=20000.m3u8",
|
| 71 |
+
"vtv7": "https://live.fptplay53.net/fnxhd1/vtv7hd_vhls.smil/chunklist_b5000000.m3u8",
|
| 72 |
+
"vtv8": "https://live.fptplay53.net/epzhd1/vtv8hd_vhls.smil/chunklist.m3u8",
|
| 73 |
+
"vtv9": "https://live.fptplay53.net/fnxhd1/vtv9hd_vhls.smil/chunklist.m3u8",
|
| 74 |
+
"vtv10": "https://live.fptplay53.net/fnxch2/vtvcantho_abr.smil/chunklist.m3u8",
|
| 75 |
}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
_vtv_cache = {}
|
| 78 |
_vtv_lock = threading.Lock()
|
| 79 |
_CACHE_TTL = 180
|
|
|
|
| 88 |
with _vtv_lock:
|
| 89 |
_vtv_cache[key] = {'t': time.time(), 'd': data}
|
| 90 |
|
| 91 |
+
def extract_m3u8_from_html(html):
|
| 92 |
+
if not html:
|
| 93 |
+
return None
|
| 94 |
+
m = re.search(r"file\s*:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html, re.IGNORECASE)
|
| 95 |
+
if m:
|
| 96 |
+
url = m.group(1).strip()
|
| 97 |
+
if len(url) > 20:
|
| 98 |
return url
|
| 99 |
+
m = re.search(r"(https?://[^\s\"'<>\\]+\.m3u8[^\s\"'<>\\]*)", html, re.IGNORECASE)
|
| 100 |
+
if m:
|
| 101 |
+
url = m.group(1).strip()
|
| 102 |
+
if len(url) > 20:
|
| 103 |
+
return url
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
def fetch_xemtv_stream(channel_id):
|
| 107 |
+
php_url = XEMTV_PHP_ENDPOINTS.get(channel_id)
|
| 108 |
+
if not php_url:
|
| 109 |
+
return None
|
| 110 |
+
try:
|
| 111 |
+
r = requests.get(php_url, headers=UA, timeout=15, allow_redirects=True)
|
| 112 |
+
if r.status_code == 200:
|
| 113 |
+
m3u8 = extract_m3u8_from_html(r.text)
|
| 114 |
+
if m3u8:
|
| 115 |
+
return m3u8
|
| 116 |
except:
|
| 117 |
pass
|
| 118 |
return None
|
| 119 |
|
| 120 |
+
def fetch_fptplay_stream(channel_id):
|
| 121 |
+
url = FPTPLAY_URLS.get(channel_id)
|
| 122 |
+
if not url:
|
| 123 |
+
return None
|
| 124 |
try:
|
| 125 |
headers = {"User-Agent": UA["User-Agent"], "Referer": "https://fptplay.vn/", "Origin": "https://fptplay.vn"}
|
| 126 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 127 |
+
if r.status_code == 200:
|
| 128 |
return url
|
| 129 |
except:
|
| 130 |
pass
|
| 131 |
return None
|
| 132 |
|
| 133 |
def fetch_vtv_stream(channel_id):
|
|
|
|
| 134 |
channel_id = channel_id.lower().strip()
|
|
|
|
|
|
|
| 135 |
name_map = {
|
| 136 |
'vtvct': 'vtv10', 'vtv-can-tho': 'vtv10', 'vtv can tho': 'vtv10',
|
| 137 |
'vtv_can_tho': 'vtv10', 'cantho': 'vtv10',
|
|
|
|
| 140 |
'vietnam_vtv7': 'vtv7', 'vietnam_vtv8': 'vtv8', 'vietnam_vtv9': 'vtv9',
|
| 141 |
}
|
| 142 |
channel_id = name_map.get(channel_id, channel_id)
|
|
|
|
| 143 |
cached = _cached(channel_id)
|
| 144 |
if cached is not None:
|
| 145 |
return cached
|
| 146 |
|
| 147 |
+
if channel_id in XEMTV_ONLY_CHANNELS:
|
| 148 |
+
xemtv_url = fetch_xemtv_stream(channel_id)
|
| 149 |
+
if xemtv_url:
|
| 150 |
+
_set_cache(channel_id, xemtv_url)
|
| 151 |
+
return xemtv_url
|
| 152 |
+
fpt_url = FPTPLAY_URLS.get(channel_id)
|
| 153 |
+
if fpt_url:
|
| 154 |
+
_set_cache(channel_id, fpt_url)
|
| 155 |
+
return fpt_url
|
| 156 |
_set_cache(channel_id, None)
|
| 157 |
return None
|
| 158 |
|
| 159 |
+
if channel_id in VTVGO_FAILOVER:
|
| 160 |
+
result = VTVGO_FAILOVER[channel_id]
|
| 161 |
+
_set_cache(channel_id, result)
|
| 162 |
+
return result
|
|
|
|
|
|
|
| 163 |
|
| 164 |
+
if channel_id == 'vtvprime':
|
| 165 |
+
xemtv_url = fetch_xemtv_stream('vtvprime')
|
| 166 |
+
if xemtv_url:
|
| 167 |
+
_set_cache(channel_id, xemtv_url)
|
| 168 |
+
return xemtv_url
|
| 169 |
+
_set_cache(channel_id, None)
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
xemtv_url = fetch_xemtv_stream(channel_id)
|
| 173 |
+
if xemtv_url:
|
| 174 |
+
_set_cache(channel_id, xemtv_url)
|
| 175 |
+
return xemtv_url
|
| 176 |
+
|
| 177 |
+
fpt_url = fetch_fptplay_stream(channel_id)
|
| 178 |
+
if fpt_url:
|
| 179 |
+
_set_cache(channel_id, fpt_url)
|
| 180 |
+
return fpt_url
|
| 181 |
|
| 182 |
_set_cache(channel_id, None)
|
| 183 |
return None
|
| 184 |
|
| 185 |
@router.get("/api/vtv/streams")
|
| 186 |
def api_vtv_streams():
|
|
|
|
| 187 |
result = {}
|
| 188 |
+
for ch_id in CHANNEL_NAMES:
|
| 189 |
+
stream_url = fetch_vtv_stream(ch_id)
|
| 190 |
+
result[ch_id] = {"name": CHANNEL_NAMES[ch_id], "stream_url": stream_url, "status": "ok" if stream_url else "offline"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
return JSONResponse(result)
|
| 192 |
|
| 193 |
@router.get("/api/vtv/stream/{channel_id}")
|
| 194 |
def api_vtv_stream(channel_id: str):
|
|
|
|
| 195 |
stream_url = fetch_vtv_stream(channel_id)
|
| 196 |
if stream_url:
|
| 197 |
return JSONResponse({"stream_url": stream_url, "status": "ok"})
|
|
|
|
|
|
|
| 198 |
return JSONResponse({"error": "stream not found", "status": "offline"}, status_code=404)
|
| 199 |
|
| 200 |
@router.get("/api/proxy/page")
|
| 201 |
def proxy_page(url: str = Query(...)):
|
|
|
|
| 202 |
try:
|
| 203 |
headers = {**UA}
|
| 204 |
if "xemtv.net" in url:
|
|
|
|
| 212 |
|
| 213 |
@router.get("/api/proxy/m3u8/vtv")
|
| 214 |
def proxy_vtv_m3u8(url: str = Query(...)):
|
|
|
|
|
|
|
| 215 |
try:
|
| 216 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 217 |
+
if "fptplay" in url:
|
| 218 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 219 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 220 |
+
elif "xemtv" in url:
|
| 221 |
+
headers["Referer"] = "https://hd.xemtv.net/"
|
| 222 |
+
r = requests.get(url, headers=headers, timeout=15, allow_redirects=True)
|
| 223 |
if r.status_code != 200:
|
| 224 |
return Response(status_code=502, content="upstream error")
|
| 225 |
content = r.text
|
| 226 |
lines = content.split('\n')
|
| 227 |
rewritten = []
|
| 228 |
base_url = url.rsplit('/', 1)[0] + '/'
|
|
|
|
|
|
|
|
|
|
| 229 |
for line in lines:
|
| 230 |
line = line.strip()
|
| 231 |
if not line or line.startswith('#'):
|
|
|
|
| 234 |
seg_url = line
|
| 235 |
if not seg_url.startswith('http'):
|
| 236 |
seg_url = base_url + seg_url
|
| 237 |
+
rewritten.append("/api/proxy/seg/vtv?url=" + requests.utils.quote(seg_url, safe=""))
|
| 238 |
+
return Response(content='\n'.join(rewritten).encode("utf-8"), media_type="application/vnd.apple.mpegurl", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
except Exception as e:
|
| 240 |
return Response(status_code=502, content="proxy error: " + str(e))
|
| 241 |
|
| 242 |
@router.get("/api/proxy/seg/vtv")
|
| 243 |
def proxy_vtv_segment(url: str = Query(...)):
|
|
|
|
| 244 |
try:
|
| 245 |
+
headers = {"User-Agent": UA["User-Agent"], "Accept": "*/*"}
|
| 246 |
+
if "fptplay" in url:
|
| 247 |
+
headers["Referer"] = "https://fptplay.vn/"
|
| 248 |
+
headers["Origin"] = "https://fptplay.vn"
|
| 249 |
+
r = requests.get(url, headers=headers, timeout=30, allow_redirects=True)
|
| 250 |
if r.status_code != 200:
|
| 251 |
return Response(status_code=502, content="upstream error")
|
| 252 |
data = r.content
|
| 253 |
+
if len(data) > 188 and data[0:4] == b'\x89PNG' and data[188] == 0x47:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
data = data[188:]
|
| 255 |
+
return Response(content=data, media_type="video/mp2t", headers={"Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600"})
|
| 256 |
except:
|
| 257 |
return Response(status_code=502, content="proxy error")
|
| 258 |
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
# ===== EPG from vtv.vn/lich-phat-song.htm =====
|
| 261 |
|
| 262 |
_epg_cache = {}
|
|
|
|
| 462 |
"status": "refreshed",
|
| 463 |
"channels": len(epg_data),
|
| 464 |
"total_programmes": sum(len(v) for v in epg_data),
|
| 465 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -188,7 +188,7 @@ def get_vtvnambo_shorts(max_count=50):
|
|
| 188 |
# Method 2: /videos page with duration filter
|
| 189 |
if len(all_shorts) < 5:
|
| 190 |
print(f"[yt-dlp] Fetching /videos page with filter...")
|
| 191 |
-
videos_filtered = get_channel_videos_filter_shorts('vtvnambo',
|
| 192 |
for s in videos_filtered:
|
| 193 |
if s['id'] not in seen_ids:
|
| 194 |
seen_ids.add(s['id'])
|
|
|
|
| 188 |
# Method 2: /videos page with duration filter
|
| 189 |
if len(all_shorts) < 5:
|
| 190 |
print(f"[yt-dlp] Fetching /videos page with filter...")
|
| 191 |
+
videos_filtered = get_channel_videos_filter_shorts('vtvnambo', max_count * 2)
|
| 192 |
for s in videos_filtered:
|
| 193 |
if s['id'] not in seen_ids:
|
| 194 |
seen_ids.add(s['id'])
|