Spaces:
Paused
Paused
| """Entry point: shorts carousel + TikTok-style video playback for ALL video sources.""" | |
| import sys, os, re as _re | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from shorts_carousel import scrape_24h_news_shorts, render_shorts_carousel, SHORTS_CSS, _extract_24h_video_url | |
| import app | |
| # ══ CSS Patches ══ | |
| SHORTS_EXTRA_CSS = """ | |
| /* Shorts feed: video 9:16 crop center */ | |
| .shorts-feed-mode .tiktok-video{object-fit:cover!important} | |
| /* TikTok feed fullwidth */ | |
| .tiktok-fullscreen-container{width:100%!important;max-width:100%!important;border-radius:0!important} | |
| /* Reduce TikTok player height on mobile */ | |
| .tiktok-fullscreen-container{height:80vh!important;max-height:680px!important;min-height:400px!important} | |
| .tiktok-fullscreen-feed{height:100%!important} | |
| .tiktok-slide{height:80vh!important;max-height:680px!important;min-height:400px!important} | |
| /* Fullheight toggle for highlight */ | |
| .tiktok-fullscreen-container.fullheight-mode .tiktok-video{object-fit:cover!important} | |
| .tiktok-fullheight-btn{position:absolute;bottom:70px;right:12px;background:rgba(255,255,255,.15);color:#fff;border:none;padding:6px 12px;border-radius:16px;font-size:11px;cursor:pointer;z-index:8;backdrop-filter:blur(6px)} | |
| .tiktok-fullheight-btn.active{background:rgba(92,184,122,.5)} | |
| """ | |
| FULLWIDTH_CSS = """ | |
| /* ══ FULLWIDTH: kill all Gradio padding ══ */ | |
| body,.gradio-container,.main,.contain{margin:0!important;padding:0!important;max-width:100%!important;width:100%!important} | |
| .gradio-container>.main{padding:0!important} | |
| .gradio-container>.main>.contain{padding:0!important} | |
| .gradio-container>.main>.contain>div{padding:0!important;margin:0!important} | |
| .gradio-container>.main>.contain>div>div{padding:0!important} | |
| #component-0,#component-1,#component-2,#component-3,#component-4,#component-5{padding:0!important;margin:0!important} | |
| .gap{gap:0!important} | |
| .panel,.form,.block{padding:0!important} | |
| div[class*="svelte"]{padding-left:0!important;padding-right:0!important} | |
| .bdp-wrap{padding:6px 4px!important} | |
| .bdp-article{padding:12px 8px 30px!important;max-width:100%!important} | |
| .controls-row{padding:0 4px!important} | |
| .vslide-wrap{margin:4px 2px!important;border-radius:8px!important} | |
| .bdp-header{padding:12px 8px!important} | |
| @media(min-width:768px){ | |
| .bdp-wrap{padding:8px!important} | |
| .bdp-article{padding:16px 12px 40px!important;max-width:720px!important} | |
| .vslide-wrap{margin:8px!important;border-radius:12px!important} | |
| } | |
| """ | |
| if "vslide-shorts-item" not in app.CSS: | |
| app.CSS += SHORTS_CSS | |
| if "shorts-feed-mode" not in app.CSS: | |
| app.CSS += SHORTS_EXTRA_CSS | |
| if "FULLWIDTH: kill all Gradio padding" not in app.CSS: | |
| app.CSS += FULLWIDTH_CSS | |
| # ══ Override app._extract_24h_video_urls ══ | |
| def _fixed_extract_24h_video_urls(article_url): | |
| vid = _extract_24h_video_url(article_url) | |
| if not vid: return [] | |
| all_parts = vid.get("all_parts", [vid["src"]]) | |
| return [{"src": p, "poster": vid.get("poster",""), "vtype": "hls"} for p in all_parts] | |
| app._extract_24h_video_urls = _fixed_extract_24h_video_urls | |
| # ══ Fix homepage slider ══ | |
| _orig_render_video_slider = getattr(app, 'render_video_slider_html', None) | |
| if _orig_render_video_slider: | |
| def _patched_render_video_slider(videos): | |
| html = _orig_render_video_slider(videos) | |
| html = html.replace("window.bdpOpenTikTok(", "window.bdpOpen(") | |
| html = _re.sub(r"window\.bdpOpen\('([^']+)','([^']+)'\)", r"window.bdpOpen('\1','\2','')", html) | |
| return html | |
| app.render_video_slider_html = _patched_render_video_slider | |
| # ══ Patch render_homepage_html: add shorts carousel ══ | |
| _orig_render_homepage = app.render_homepage_html | |
| def _patched_render_homepage(articles, *args, **kwargs): | |
| html = _orig_render_homepage(articles, *args, **kwargs) | |
| try: | |
| shorts = scrape_24h_news_shorts()[:15] | |
| shorts_html = render_shorts_carousel(shorts, app.esc, app.safe_url, app.make_id, app.slug) | |
| if shorts_html: | |
| pt = html.find('<div class="vslide-wrap">') | |
| if pt > 0: | |
| html = html[:pt] + shorts_html + html[pt:] | |
| else: | |
| pt = html.find('<div class="bdp-wrap">') + len('<div class="bdp-wrap">') | |
| if pt > len('<div class="bdp-wrap">'): | |
| html = html[:pt] + shorts_html + html[pt:] | |
| except Exception as e: | |
| print(f"[shorts] Error: {e}") | |
| return html | |
| app.render_homepage_html = _patched_render_homepage | |
| # ══ Patch render_video_page_html: fullheight button ══ | |
| _orig_render_video_page = app.render_video_page_html | |
| def _patched_render_video_page(): | |
| html = _orig_render_video_page() | |
| toggle_btn = '<button class="tiktok-fullheight-btn" onclick="event.stopPropagation();var c=this.closest(\'.tiktok-fullscreen-container\');c.classList.toggle(\'fullheight-mode\');var on=c.classList.contains(\'fullheight-mode\');c.querySelectorAll(\'.tiktok-fullheight-btn\').forEach(function(b){b.classList.toggle(\'active\',on);b.textContent=on?\'\\u2B07 Contain\':\'\\u2922 Fullheight\';});">\u2922 Fullheight</button>' | |
| html = html.replace('<div class="tiktok-unmute-hint"', toggle_btn + '<div class="tiktok-unmute-hint"') | |
| return html | |
| app.render_video_page_html = _patched_render_video_page | |
| # ══ Patch read_article ══ | |
| _orig_read_article = app.read_article | |
| def _patched_read_article(url): | |
| if not url or url == "#" or len(url) < 10: | |
| return "<p>Không tìm thấy bài viết.</p>" | |
| if "24h.com.vn" in url: | |
| is_highlight = "/bong-da/video-bong-da-" in url or "video-highlight" in url | |
| result = _render_highlight_tiktok_feed(url) if is_highlight else _render_shorts_tiktok_feed(url) | |
| if result: return result | |
| if "bongdaplus.vn/video/" in url: | |
| result = _render_bdp_tiktok_feed(url) | |
| if result: return result | |
| return _orig_read_article(url) | |
| def _render_bdp_tiktok_feed(current_url): | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| try: bdp_list = app.scrape_bdp_video_list()[:15] | |
| except: return None | |
| if not bdp_list: return None | |
| def _fetch_bdp(art): | |
| vid_id = app._extract_bdp_video_id(art["link"]) | |
| if not vid_id: return None | |
| embed = app.fetch_bdp_embed_data(vid_id) | |
| if not embed or not embed.get("mp4"): return None | |
| return [{"title": art["title"], "link": art["link"], "img": art.get("img",""), | |
| "src": embed["mp4"], "poster": embed.get("poster",""), "vtype": "mp4"}] | |
| videos = [] | |
| with ThreadPoolExecutor(max_workers=6) as ex: | |
| futures = {ex.submit(_fetch_bdp, a): a for a in bdp_list} | |
| for f in as_completed(futures): | |
| try: | |
| r = f.result() | |
| if r: videos.extend(r) | |
| except: pass | |
| return _build_tiktok_html(videos, current_url, "BDP") if videos else None | |
| def _render_shorts_tiktok_feed(current_url): | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| try: all_articles = scrape_24h_news_shorts()[:15] | |
| except: return None | |
| if not all_articles: return None | |
| def _fetch_video(art): | |
| vid = _extract_24h_video_url(art["link"]) | |
| if not vid: return [] | |
| return [{"title": art["title"], "link": art["link"], "img": art.get("img",""), | |
| "src": vid["src"], "poster": vid["poster"], "vtype": vid["vtype"]}] | |
| videos = [] | |
| with ThreadPoolExecutor(max_workers=8) as ex: | |
| futures = {ex.submit(_fetch_video, a): a for a in all_articles} | |
| for f in as_completed(futures): | |
| try: | |
| r = f.result() | |
| if r: videos.extend(r) | |
| except: pass | |
| return _build_tiktok_html(videos, current_url, "24h") if videos else None | |
| def _render_highlight_tiktok_feed(current_url): | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| try: all_articles = app.scrape_24h_video_list()[:20] | |
| except: return None | |
| if not all_articles: return None | |
| def _fetch_video(art): | |
| vid = _extract_24h_video_url(art["link"]) | |
| if not vid: return [] | |
| parts = vid.get("all_parts", [vid["src"]]) | |
| return [{"title": art["title"]+(f" (Phần {i+1})" if len(parts)>1 else ""), "link": art["link"], | |
| "img": art.get("img",""), "src": p, "poster": vid["poster"], "vtype": vid["vtype"]} | |
| for i, p in enumerate(parts)] | |
| videos = [] | |
| with ThreadPoolExecutor(max_workers=8) as ex: | |
| futures = {ex.submit(_fetch_video, a): a for a in all_articles} | |
| for f in as_completed(futures): | |
| try: | |
| r = f.result() | |
| if r: videos.extend(r) | |
| except: pass | |
| return _build_tiktok_html(videos, current_url, "24h") if videos else None | |
| def _build_tiktok_html(videos, current_url, source_label): | |
| current_aid = app.make_id(current_url) | |
| ordered = [v for v in videos if app.make_id(v["link"])==current_aid] + [v for v in videos if app.make_id(v["link"])!=current_aid] | |
| badge_cls = "bdp-badge-24h" if source_label == "24h" else "bdp-badge-bdp" | |
| is_shorts = source_label == "24h" | |
| # og:image meta for share preview | |
| first_img = "" | |
| if ordered: | |
| fi = app.safe_url(ordered[0].get("img") or ordered[0].get("poster","")) | |
| ft = app.esc(ordered[0].get("title","")) | |
| first_img = f'<div style="display:none"><meta itemprop="image" content="{fi}"><meta itemprop="name" content="{ft}"></div>' | |
| slides = [] | |
| for vi, v in enumerate(ordered): | |
| poster = app.safe_url(v.get("poster","")) | |
| poster_attr = f' poster="{poster}"' if poster else "" | |
| vsrc = v["src"] | |
| aid = app.make_id(v["link"]) | |
| sl = app.slug(v["title"]) | |
| share_js = f"event.stopPropagation();window.bdpShareHash('{app.esc(v['title'])}','{sl}','{aid}')" | |
| vtag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} data-hls-src="{vsrc}" muted loop></video>' if v.get("vtype")=="hls" else f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} muted loop><source src="{app.safe_url(vsrc)}" type="video/mp4"></video>' | |
| fullheight_btn = '' | |
| if not is_shorts: | |
| fullheight_btn = '<button class="tiktok-fullheight-btn" onclick="event.stopPropagation();var c=this.closest(\'.tiktok-fullscreen-container\');c.classList.toggle(\'fullheight-mode\');var on=c.classList.contains(\'fullheight-mode\');c.querySelectorAll(\'.tiktok-fullheight-btn\').forEach(function(b){b.classList.toggle(\'active\',on);b.textContent=on?\'\\u2B07 Contain\':\'\\u2922 Fullheight\';});">\u2922 Fullheight</button>' | |
| slides.append(f'''<div class="tiktok-slide" data-index="{vi}" data-aid="{aid}"> | |
| {vtag} | |
| <div class="tiktok-pause-icon">\u25b6</div> | |
| <div class="tiktok-seek-controls"> | |
| <button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,-10)">\u23ea 10s</button> | |
| <button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,10)">10s \u23e9</button> | |
| </div> | |
| <div class="tiktok-bottom"> | |
| <span class="bdp-badge {badge_cls}">{source_label}</span> | |
| <p class="tiktok-title">{v["title"]}</p> | |
| <div class="tiktok-actions"> | |
| <button class="tiktok-action-btn" onclick="{share_js}">\U0001f4e4 Chia s\u1ebb</button> | |
| </div> | |
| </div> | |
| {fullheight_btn} | |
| <div class="tiktok-unmute-hint" onclick="window.bdpTikTokUnmute(this)">\U0001f507 B\u1eadt ti\u1ebfng</div> | |
| <span class="tiktok-counter">{vi+1}/{len(ordered)}</span> | |
| </div>''') | |
| cards = [] | |
| for v in ordered: | |
| img = app.safe_url(v.get("img") or v.get("poster","")) | |
| aid = app.make_id(v["link"]) | |
| sl = app.slug(v["title"]) | |
| cards.append(f'<div class="bdp-card" onclick="window.bdpOpen(\'{app.esc(v["link"])}\',\'{aid}\',\'{sl}\')"><div class="bdp-card-img"><img src="{img}" alt="" class="bdp-lazy-img"><div class="bdp-play-overlay">\u25b6</div></div><div class="bdp-card-body"><span class="bdp-badge {badge_cls}">{source_label}</span><h3 class="bdp-card-title">{v["title"]}</h3></div></div>') | |
| grid = f'<div class="bdp-wrap"><div class="bdp-topbar"><span>\U0001f3ac Video</span><span>{len(ordered)} video</span></div><div class="bdp-grid">{"".join(cards)}</div></div>' | |
| mode_class = "shorts-feed-mode" if is_shorts else "" | |
| return f'''{first_img}<div class="tiktok-fullscreen-container {mode_class}" id="tiktok-fullscreen-feed"> | |
| <div class="tiktok-fullscreen-feed">{''.join(slides)}</div> | |
| </div> | |
| {grid}''' | |
| app.read_article = _patched_read_article | |
| app.demo.css = app.CSS | |
| demo = app.demo | |
| if __name__ == "__main__": demo.launch() | |