| """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 |
|
|
| |
| 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} |
| /* 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 |
|
|
| |
| 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"]]) |
| poster = vid.get("poster", "") |
| return [{"src": p, "poster": poster, "vtype": "hls"} for p in all_parts] |
| app._extract_24h_video_urls = _fixed_extract_24h_video_urls |
|
|
| |
| _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 |
|
|
| |
| _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: |
| insert_point = html.find('<div class="vslide-wrap">') |
| if insert_point > 0: |
| html = html[:insert_point] + shorts_html + html[insert_point:] |
| else: |
| insert_point = html.find('<div class="bdp-wrap">') + len('<div class="bdp-wrap">') |
| if insert_point > len('<div class="bdp-wrap">'): |
| html = html[:insert_point] + shorts_html + html[insert_point:] |
| except Exception as e: |
| print(f"[shorts] Error: {e}") |
| return html |
| app.render_homepage_html = _patched_render_homepage |
|
|
| |
| _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 |
|
|
| |
| _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: |
| result = _render_24h_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 |
| if not videos: |
| return None |
| return _build_tiktok_html(videos, current_url, "BDP") |
|
|
| def _render_24h_tiktok_feed(current_url): |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| try: |
| all_articles = app.scrape_24h_video_list()[:20] |
| except: |
| all_articles = [] |
| if len(all_articles) < 10: |
| try: |
| shorts = scrape_24h_news_shorts()[:10] |
| seen = {a["link"] for a in all_articles} |
| for s in shorts: |
| if s["link"] not in seen: |
| all_articles.append(s) |
| except: pass |
| if not all_articles: |
| return None |
|
|
| def _fetch_video(art): |
| vid = _extract_24h_video_url(art["link"]) |
| if not vid: |
| return [] |
| results = [] |
| all_parts = vid.get("all_parts", [vid["src"]]) |
| for pi, part_url in enumerate(all_parts): |
| label = f" (Phần {pi+1})" if len(all_parts) > 1 else "" |
| results.append({ |
| "title": art["title"] + label, "link": art["link"], |
| "img": art.get("img", ""), "src": part_url, |
| "poster": vid["poster"], "vtype": vid["vtype"] |
| }) |
| return results |
|
|
| 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 |
| if not videos: |
| return None |
| return _build_tiktok_html(videos, current_url, "24h") |
|
|
| def _build_tiktok_html(videos, current_url, source_label): |
| current_aid = app.make_id(current_url) |
| ordered = [] |
| rest = [] |
| for v in videos: |
| if app.make_id(v["link"]) == current_aid: |
| ordered.append(v) |
| else: |
| rest.append(v) |
| ordered.extend(rest) |
|
|
| badge_cls = "bdp-badge-24h" if source_label == "24h" else "bdp-badge-bdp" |
| is_shorts = source_label == "24h" |
|
|
| 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}')" |
| if v.get("vtype") == "hls": |
| vtag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} data-hls-src="{vsrc}" muted loop></video>' |
| else: |
| vtag = 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>''') |
|
|
| list_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"]) |
| click = f"window.bdpOpen('{app.esc(v['link'])}','{aid}','{sl}')" |
| list_cards.append(f'''<div class="bdp-card" onclick="{click}"> |
| <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_html = f'''<div class="bdp-wrap"><div class="bdp-topbar"><span>\U0001f3ac Video</span><span>{len(ordered)} video</span></div> |
| <div class="bdp-grid">{''.join(list_cards)}</div></div>''' |
|
|
| mode_class = "shorts-feed-mode" if is_shorts else "" |
| return f'''<div class="tiktok-fullscreen-container {mode_class}" id="tiktok-fullscreen-feed"> |
| <div class="tiktok-fullscreen-feed">{''.join(slides)}</div> |
| </div> |
| {grid_html}''' |
|
|
| app.read_article = _patched_read_article |
|
|
| |
| app.demo.css = app.CSS |
|
|
| |
| demo = app.demo |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|