bep40 commited on
Commit
1c9d9ab
·
verified ·
1 Parent(s): 8304f76

fix: async shorts, 3:4 all video feeds, fix dark screen overlay, shorts TikTok feed

Browse files
Files changed (1) hide show
  1. app_wrapper.py +125 -223
app_wrapper.py CHANGED
@@ -1,269 +1,171 @@
1
- """Entry point: shorts carousel + TikTok-style video playback for ALL video sources."""
2
  import sys, os, re as _re
 
3
 
4
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
5
  from shorts_carousel import scrape_24h_news_shorts, render_shorts_carousel, SHORTS_CSS, _extract_24h_video_url
6
 
7
  import app
8
 
9
- # ══ CSS Patches ══
10
- SHORTS_EXTRA_CSS = """
11
- /* Shorts feed: video 9:16 crop center */
12
- .shorts-feed-mode .tiktok-video{object-fit:cover!important}
13
- /* TikTok feed fullwidth */
14
  .tiktok-fullscreen-container{width:100%!important;max-width:100%!important;border-radius:0!important}
15
- /* Fullheight toggle for highlight */
16
- .tiktok-fullscreen-container.fullheight-mode .tiktok-video{object-fit:cover!important}
17
  .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)}
18
  .tiktok-fullheight-btn.active{background:rgba(92,184,122,.5)}
19
- """
20
-
21
- FULLWIDTH_CSS = """
22
- /* ══ FULLWIDTH: kill all Gradio padding ══ */
23
  body,.gradio-container,.main,.contain{margin:0!important;padding:0!important;max-width:100%!important;width:100%!important}
24
- .gradio-container>.main{padding:0!important}
25
- .gradio-container>.main>.contain{padding:0!important}
26
- .gradio-container>.main>.contain>div{padding:0!important;margin:0!important}
27
- .gradio-container>.main>.contain>div>div{padding:0!important}
28
  #component-0,#component-1,#component-2,#component-3,#component-4,#component-5{padding:0!important;margin:0!important}
29
- .gap{gap:0!important}
30
- .panel,.form,.block{padding:0!important}
31
  div[class*="svelte"]{padding-left:0!important;padding-right:0!important}
32
  .bdp-wrap{padding:6px 4px!important}
33
  .bdp-article{padding:12px 8px 30px!important;max-width:100%!important}
34
  .controls-row{padding:0 4px!important}
35
  .vslide-wrap{margin:4px 2px!important;border-radius:8px!important}
36
  .bdp-header{padding:12px 8px!important}
37
- @media(min-width:768px){
38
- .bdp-wrap{padding:8px!important}
39
- .bdp-article{padding:16px 12px 40px!important;max-width:720px!important}
40
- .vslide-wrap{margin:8px!important;border-radius:12px!important}
41
- }
42
  """
43
 
44
  if "vslide-shorts-item" not in app.CSS:
45
  app.CSS += SHORTS_CSS
46
- if "shorts-feed-mode" not in app.CSS:
47
- app.CSS += SHORTS_EXTRA_CSS
48
- if "FULLWIDTH: kill all Gradio padding" not in app.CSS:
49
- app.CSS += FULLWIDTH_CSS
50
-
51
- # ══ Override app._extract_24h_video_urls with fixed multi-part version ══
52
- def _fixed_extract_24h_video_urls(article_url):
53
- vid = _extract_24h_video_url(article_url)
54
- if not vid:
55
- return []
56
- all_parts = vid.get("all_parts", [vid["src"]])
57
- poster = vid.get("poster", "")
58
- return [{"src": p, "poster": poster, "vtype": "hls"} for p in all_parts]
59
- app._extract_24h_video_urls = _fixed_extract_24h_video_urls
60
-
61
- # ══ Fix homepage slider: bdpOpenTikTok → bdpOpen with 3 params ══
62
- _orig_render_video_slider = getattr(app, 'render_video_slider_html', None)
63
- if _orig_render_video_slider:
64
- def _patched_render_video_slider(videos):
65
- html = _orig_render_video_slider(videos)
66
  html = html.replace("window.bdpOpenTikTok(", "window.bdpOpen(")
67
- html = _re.sub(
68
- r"window\.bdpOpen\('([^']+)','([^']+)'\)",
69
- r"window.bdpOpen('\1','\2','')",
70
- html
71
- )
72
  return html
73
- app.render_video_slider_html = _patched_render_video_slider
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- # ══ Patch render_homepage_html: add shorts carousel ══
76
- _orig_render_homepage = app.render_homepage_html
77
- def _patched_render_homepage(articles, *args, **kwargs):
78
- html = _orig_render_homepage(articles, *args, **kwargs)
79
  try:
80
- shorts = scrape_24h_news_shorts()[:15]
81
  shorts_html = render_shorts_carousel(shorts, app.esc, app.safe_url, app.make_id, app.slug)
82
  if shorts_html:
83
- insert_point = html.find('<div class="vslide-wrap">')
84
- if insert_point > 0:
85
- html = html[:insert_point] + shorts_html + html[insert_point:]
86
  else:
87
- insert_point = html.find('<div class="bdp-wrap">') + len('<div class="bdp-wrap">')
88
- if insert_point > len('<div class="bdp-wrap">'):
89
- html = html[:insert_point] + shorts_html + html[insert_point:]
90
- except Exception as e:
91
- print(f"[shorts] Error: {e}")
92
  return html
93
- app.render_homepage_html = _patched_render_homepage
94
-
95
- # ══ Patch render_video_page_html: fullheight button ══
96
- _orig_render_video_page = app.render_video_page_html
97
- def _patched_render_video_page():
98
- html = _orig_render_video_page()
99
- 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>'
100
- html = html.replace('<div class="tiktok-unmute-hint"', toggle_btn + '<div class="tiktok-unmute-hint"')
101
  return html
102
- app.render_video_page_html = _patched_render_video_page
103
 
104
- # ══ Patch read_article: video URLs → TikTok feed ══
105
- _orig_read_article = app.read_article
106
- def _patched_read_article(url):
107
- if not url or url == "#" or len(url) < 10:
108
  return "<p>Không tìm thấy bài viết.</p>"
109
  if "24h.com.vn" in url:
110
- result = _render_24h_tiktok_feed(url)
111
- if result:
112
- return result
113
  if "bongdaplus.vn/video/" in url:
114
- result = _render_bdp_tiktok_feed(url)
115
- if result:
116
- return result
117
- return _orig_read_article(url)
118
-
119
- def _render_bdp_tiktok_feed(current_url):
120
- from concurrent.futures import ThreadPoolExecutor, as_completed
121
- try:
122
- bdp_list = app.scrape_bdp_video_list()[:15]
123
- except:
124
- return None
125
- if not bdp_list:
126
- return None
127
- def _fetch_bdp(art):
128
- vid_id = app._extract_bdp_video_id(art["link"])
129
- if not vid_id: return None
130
- embed = app.fetch_bdp_embed_data(vid_id)
131
- if not embed or not embed.get("mp4"): return None
132
- return [{"title": art["title"], "link": art["link"], "img": art.get("img",""),
133
- "src": embed["mp4"], "poster": embed.get("poster",""), "vtype": "mp4"}]
134
- videos = []
135
- with ThreadPoolExecutor(max_workers=6) as ex:
136
- futures = {ex.submit(_fetch_bdp, a): a for a in bdp_list}
137
- for f in as_completed(futures):
138
- try:
139
- r = f.result()
140
- if r: videos.extend(r)
141
- except: pass
142
- if not videos:
143
- return None
144
- return _build_tiktok_html(videos, current_url, "BDP")
145
 
146
- def _render_24h_tiktok_feed(current_url):
147
  from concurrent.futures import ThreadPoolExecutor, as_completed
148
- try:
149
- all_articles = app.scrape_24h_video_list()[:20]
150
- except:
151
- all_articles = []
152
- if len(all_articles) < 10:
153
- try:
154
- shorts = scrape_24h_news_shorts()[:10]
155
- seen = {a["link"] for a in all_articles}
156
- for s in shorts:
157
- if s["link"] not in seen:
158
- all_articles.append(s)
159
- except: pass
160
- if not all_articles:
161
- return None
162
-
163
- def _fetch_video(art):
164
- vid = _extract_24h_video_url(art["link"])
165
- if not vid:
166
- return []
167
- results = []
168
- all_parts = vid.get("all_parts", [vid["src"]])
169
- for pi, part_url in enumerate(all_parts):
170
- label = f" (Phần {pi+1})" if len(all_parts) > 1 else ""
171
- results.append({
172
- "title": art["title"] + label, "link": art["link"],
173
- "img": art.get("img", ""), "src": part_url,
174
- "poster": vid["poster"], "vtype": vid["vtype"]
175
- })
176
- return results
177
-
178
- videos = []
179
  with ThreadPoolExecutor(max_workers=8) as ex:
180
- futures = {ex.submit(_fetch_video, a): a for a in all_articles}
181
- for f in as_completed(futures):
182
  try:
183
- r = f.result()
184
- if r: videos.extend(r)
185
  except: pass
186
- if not videos:
187
- return None
188
- return _build_tiktok_html(videos, current_url, "24h")
189
-
190
- def _build_tiktok_html(videos, current_url, source_label):
191
- current_aid = app.make_id(current_url)
192
- ordered = []
193
- rest = []
194
- for v in videos:
195
- if app.make_id(v["link"]) == current_aid:
196
- ordered.append(v)
197
- else:
198
- rest.append(v)
199
- ordered.extend(rest)
200
-
201
- badge_cls = "bdp-badge-24h" if source_label == "24h" else "bdp-badge-bdp"
202
- is_shorts = source_label == "24h"
203
-
204
- slides = []
205
- for vi, v in enumerate(ordered):
206
- poster = app.safe_url(v.get("poster", ""))
207
- poster_attr = f' poster="{poster}"' if poster else ""
208
- vsrc = v["src"]
209
  aid = app.make_id(v["link"])
210
  sl = app.slug(v["title"])
211
- share_js = f"event.stopPropagation();window.bdpShareHash('{app.esc(v['title'])}','{sl}','{aid}')"
212
- if v.get("vtype") == "hls":
213
- vtag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} data-hls-src="{vsrc}" muted loop></video>'
214
- else:
215
- vtag = f'<video class="tiktok-video" playsinline preload="metadata"{poster_attr} muted loop><source src="{app.safe_url(vsrc)}" type="video/mp4"></video>'
216
-
217
- fullheight_btn = ''
218
- if not is_shorts:
219
- 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>'
220
-
221
- slides.append(f'''<div class="tiktok-slide" data-index="{vi}" data-aid="{aid}">
222
- {vtag}
223
- <div class="tiktok-pause-icon">\u25b6</div>
224
- <div class="tiktok-seek-controls">
225
- <button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,-10)">\u23ea 10s</button>
226
- <button class="tiktok-seek-btn" onclick="event.stopPropagation();window.bdpSeek(this,10)">10s \u23e9</button>
227
- </div>
228
- <div class="tiktok-bottom">
229
- <span class="bdp-badge {badge_cls}">{source_label}</span>
230
- <p class="tiktok-title">{v["title"]}</p>
231
- <div class="tiktok-actions">
232
- <button class="tiktok-action-btn" onclick="{share_js}">\U0001f4e4 Chia s\u1ebb</button>
233
- </div>
234
- </div>
235
- {fullheight_btn}
236
- <div class="tiktok-unmute-hint" onclick="window.bdpTikTokUnmute(this)">\U0001f507 B\u1eadt ti\u1ebfng</div>
237
- <span class="tiktok-counter">{vi+1}/{len(ordered)}</span>
238
- </div>''')
239
-
240
- list_cards = []
241
  for v in ordered:
242
- img = app.safe_url(v.get("img") or v.get("poster",""))
243
- aid = app.make_id(v["link"])
244
- sl = app.slug(v["title"])
245
- click = f"window.bdpOpen('{app.esc(v['link'])}','{aid}','{sl}')"
246
- list_cards.append(f'''<div class="bdp-card" onclick="{click}">
247
- <div class="bdp-card-img"><img src="{img}" alt="" class="bdp-lazy-img"><div class="bdp-play-overlay">\u25b6</div></div>
248
- <div class="bdp-card-body"><span class="bdp-badge {badge_cls}">{source_label}</span>
249
- <h3 class="bdp-card-title">{v["title"]}</h3></div></div>''')
250
-
251
- grid_html = f'''<div class="bdp-wrap"><div class="bdp-topbar"><span>\U0001f3ac Video</span><span>{len(ordered)} video</span></div>
252
- <div class="bdp-grid">{''.join(list_cards)}</div></div>'''
253
-
254
- mode_class = "shorts-feed-mode" if is_shorts else ""
255
- return f'''<div class="tiktok-fullscreen-container {mode_class}" id="tiktok-fullscreen-feed">
256
- <div class="tiktok-fullscreen-feed">{''.join(slides)}</div>
257
- </div>
258
- {grid_html}'''
259
 
260
- app.read_article = _patched_read_article
261
-
262
- # Patch CSS on demo
263
  app.demo.css = app.CSS
264
-
265
- # Export demo
266
  demo = app.demo
267
-
268
- if __name__ == "__main__":
269
- demo.launch()
 
1
+ """Entry point: shorts + TikTok video feeds."""
2
  import sys, os, re as _re
3
+ from concurrent.futures import ThreadPoolExecutor
4
 
5
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
  from shorts_carousel import scrape_24h_news_shorts, render_shorts_carousel, SHORTS_CSS, _extract_24h_video_url
7
 
8
  import app
9
 
10
+ # ══ CSS ══
11
+ EXTRA_CSS = """
12
+ /* ALL TikTok feeds: 3:4 crop center */
13
+ .tiktok-fullscreen-container .tiktok-video{object-fit:cover!important}
 
14
  .tiktok-fullscreen-container{width:100%!important;max-width:100%!important;border-radius:0!important}
15
+ /* Contain toggle */
16
+ .tiktok-fullscreen-container.contain-mode .tiktok-video{object-fit:contain!important}
17
  .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)}
18
  .tiktok-fullheight-btn.active{background:rgba(92,184,122,.5)}
19
+ /* Fix dark screen: pause icon should not block video */
20
+ .tiktok-pause-icon{pointer-events:none!important;background:rgba(0,0,0,.35)!important}
21
+ .tiktok-slide:not(.paused) .tiktok-pause-icon{display:none!important}
22
+ /* FULLWIDTH */
23
  body,.gradio-container,.main,.contain{margin:0!important;padding:0!important;max-width:100%!important;width:100%!important}
24
+ .gradio-container>.main,.gradio-container>.main>.contain,.gradio-container>.main>.contain>div,.gradio-container>.main>.contain>div>div{padding:0!important;margin:0!important}
 
 
 
25
  #component-0,#component-1,#component-2,#component-3,#component-4,#component-5{padding:0!important;margin:0!important}
26
+ .gap,.panel,.form,.block{gap:0!important;padding:0!important}
 
27
  div[class*="svelte"]{padding-left:0!important;padding-right:0!important}
28
  .bdp-wrap{padding:6px 4px!important}
29
  .bdp-article{padding:12px 8px 30px!important;max-width:100%!important}
30
  .controls-row{padding:0 4px!important}
31
  .vslide-wrap{margin:4px 2px!important;border-radius:8px!important}
32
  .bdp-header{padding:12px 8px!important}
33
+ @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}}
 
 
 
 
34
  """
35
 
36
  if "vslide-shorts-item" not in app.CSS:
37
  app.CSS += SHORTS_CSS
38
+ if "3:4 crop center" not in app.CSS:
39
+ app.CSS += EXTRA_CSS
40
+
41
+ # ══ Multi-part override ══
42
+ def _fixed_extract(url):
43
+ vid = _extract_24h_video_url(url)
44
+ if not vid: return []
45
+ parts = vid.get("all_parts", [vid["src"]])
46
+ return [{"src": p, "poster": vid.get("poster",""), "vtype": "hls"} for p in parts]
47
+ app._extract_24h_video_urls = _fixed_extract
48
+
49
+ # ══ Slider fix ══
50
+ _orig_slider = getattr(app, 'render_video_slider_html', None)
51
+ if _orig_slider:
52
+ def _patch_slider(videos):
53
+ html = _orig_slider(videos)
 
 
 
 
54
  html = html.replace("window.bdpOpenTikTok(", "window.bdpOpen(")
55
+ html = _re.sub(r"window\.bdpOpen\('([^']+)','([^']+)'\)", r"window.bdpOpen('\1','\2','')", html)
 
 
 
 
56
  return html
57
+ app.render_video_slider_html = _patch_slider
58
+
59
+ # ══ Homepage: shorts carousel (fetched ASYNC, cached) ══
60
+ _shorts_cache = {"data": None}
61
+
62
+ def _get_shorts():
63
+ """Get shorts with simple cache to avoid re-fetching on timer refresh."""
64
+ if _shorts_cache["data"] is None:
65
+ try:
66
+ _shorts_cache["data"] = scrape_24h_news_shorts()[:15]
67
+ except:
68
+ _shorts_cache["data"] = []
69
+ return _shorts_cache["data"]
70
+
71
+ # Pre-fetch shorts in background thread at startup
72
+ ThreadPoolExecutor(1).submit(_get_shorts)
73
 
74
+ _orig_homepage = app.render_homepage_html
75
+ def _patch_homepage(articles, *args, **kwargs):
76
+ html = _orig_homepage(articles, *args, **kwargs)
 
77
  try:
78
+ shorts = _get_shorts()
79
  shorts_html = render_shorts_carousel(shorts, app.esc, app.safe_url, app.make_id, app.slug)
80
  if shorts_html:
81
+ pt = html.find('<div class="vslide-wrap">')
82
+ if pt > 0:
83
+ html = html[:pt] + shorts_html + html[pt:]
84
  else:
85
+ pt = html.find('<div class="bdp-wrap">') + len('<div class="bdp-wrap">')
86
+ if pt > len('<div class="bdp-wrap">'):
87
+ html = html[:pt] + shorts_html + html[pt:]
88
+ except: pass
 
89
  return html
90
+ app.render_homepage_html = _patch_homepage
91
+
92
+ # ══ Video page: contain button ══
93
+ _orig_vp = app.render_video_page_html
94
+ def _patch_vp():
95
+ html = _orig_vp()
96
+ btn = '<button class="tiktok-fullheight-btn" onclick="event.stopPropagation();var c=this.closest(\'.tiktok-fullscreen-container\');c.classList.toggle(\'contain-mode\');var on=c.classList.contains(\'contain-mode\');c.querySelectorAll(\'.tiktok-fullheight-btn\').forEach(function(b){b.classList.toggle(\'active\',on);b.textContent=on?\'\\u2922 3:4\':\'\\u2B07 Contain\';});">\u2B07 Contain</button>'
97
+ html = html.replace('<div class="tiktok-unmute-hint"', btn + '<div class="tiktok-unmute-hint"')
98
  return html
99
+ app.render_video_page_html = _patch_vp
100
 
101
+ # ══ read_article → TikTok feed ══
102
+ _orig_read = app.read_article
103
+ def _patch_read(url):
104
+ if not url or url=="#" or len(url)<10:
105
  return "<p>Không tìm thấy bài viết.</p>"
106
  if "24h.com.vn" in url:
107
+ r = _tiktok_feed("24h", url)
108
+ if r: return r
 
109
  if "bongdaplus.vn/video/" in url:
110
+ r = _tiktok_feed("bdp", url)
111
+ if r: return r
112
+ return _orig_read(url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ def _tiktok_feed(src, current_url):
115
  from concurrent.futures import ThreadPoolExecutor, as_completed
116
+ if src == "24h":
117
+ arts = _get_shorts()[:15] # Use cached shorts
118
+ if not arts:
119
+ try: arts = app.scrape_24h_video_list()[:15]
120
+ except: return None
121
+ def _f(a):
122
+ v = _extract_24h_video_url(a["link"])
123
+ if not v: return []
124
+ ps = v.get("all_parts", [v["src"]])
125
+ return [{"title":a["title"]+(f" (P{i+1})" if len(ps)>1 else ""),"link":a["link"],"img":a.get("img",""),"src":p,"poster":v["poster"],"vtype":"hls"} for i,p in enumerate(ps)]
126
+ else:
127
+ try: arts = app.scrape_bdp_video_list()[:15]
128
+ except: return None
129
+ def _f(a):
130
+ vid_id = app._extract_bdp_video_id(a["link"])
131
+ if not vid_id: return []
132
+ e = app.fetch_bdp_embed_data(vid_id)
133
+ if not e or not e.get("mp4"): return []
134
+ return [{"title":a["title"],"link":a["link"],"img":a.get("img",""),"src":e["mp4"],"poster":e.get("poster",""),"vtype":"mp4"}]
135
+ if not arts: return None
136
+ vids = []
 
 
 
 
 
 
 
 
 
 
137
  with ThreadPoolExecutor(max_workers=8) as ex:
138
+ for f in as_completed({ex.submit(_f,a):a for a in arts}):
 
139
  try:
140
+ r=f.result()
141
+ if r: vids.extend(r)
142
  except: pass
143
+ if not vids: return None
144
+ # Order: clicked first
145
+ ca = app.make_id(current_url)
146
+ ordered = [v for v in vids if app.make_id(v["link"])==ca] + [v for v in vids if app.make_id(v["link"])!=ca]
147
+ bg = "bdp-badge-24h" if src=="24h" else "bdp-badge-bdp"
148
+ lb = "24h" if src=="24h" else "BDP"
149
+ slides=[]
150
+ for i,v in enumerate(ordered):
151
+ po = app.safe_url(v.get("poster",""))
152
+ pa = f' poster="{po}"' if po else ""
153
+ vs = v["src"]
 
 
 
 
 
 
 
 
 
 
 
 
154
  aid = app.make_id(v["link"])
155
  sl = app.slug(v["title"])
156
+ sj = f"event.stopPropagation();window.bdpShareHash('{app.esc(v['title'])}','{sl}','{aid}')"
157
+ vt = f'<video class="tiktok-video" playsinline preload="metadata"{pa} data-hls-src="{vs}" muted loop></video>' if v.get("vtype")=="hls" else f'<video class="tiktok-video" playsinline preload="metadata"{pa} muted loop><source src="{app.safe_url(vs)}" type="video/mp4"></video>'
158
+ cb = '<button class="tiktok-fullheight-btn" onclick="event.stopPropagation();var c=this.closest(\'.tiktok-fullscreen-container\');c.classList.toggle(\'contain-mode\');var on=c.classList.contains(\'contain-mode\');c.querySelectorAll(\'.tiktok-fullheight-btn\').forEach(function(b){b.classList.toggle(\'active\',on);b.textContent=on?\'\\u2922 3:4\':\'\\u2B07 Contain\';});">\u2B07 Contain</button>'
159
+ slides.append(f'<div class="tiktok-slide" data-index="{i}" data-aid="{aid}">{vt}<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 {bg}">{lb}</span><p class="tiktok-title">{v["title"]}</p><div class="tiktok-actions"><button class="tiktok-action-btn" onclick="{sj}">\U0001f4e4</button></div></div>{cb}<div class="tiktok-unmute-hint" onclick="window.bdpTikTokUnmute(this)">\U0001f507 B\u1eadt ti\u1ebfng</div><span class="tiktok-counter">{i+1}/{len(ordered)}</span></div>')
160
+ cards=[]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  for v in ordered:
162
+ img=app.safe_url(v.get("img")or v.get("poster",""))
163
+ aid=app.make_id(v["link"]);sl=app.slug(v["title"])
164
+ 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 {bg}">{lb}</span><h3 class="bdp-card-title">{v["title"]}</h3></div></div>')
165
+ 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>'
166
+ return f'<div class="tiktok-fullscreen-container" id="tiktok-fullscreen-feed"><div class="tiktok-fullscreen-feed">{"".join(slides)}</div></div>{grid}'
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
+ app.read_article = _patch_read
 
 
169
  app.demo.css = app.CSS
 
 
170
  demo = app.demo
171
+ if __name__=="__main__": demo.launch()