Spaces:
Running
Running
| import asyncio | |
| import json | |
| import os | |
| import traceback | |
| import time | |
| from datetime import datetime | |
| from aiohttp import web | |
| from TikTokLive import TikTokLiveClient | |
| from TikTokLive.events import ( | |
| ConnectEvent, DisconnectEvent, CommentEvent, | |
| GiftEvent, FollowEvent, JoinEvent, LikeEvent, | |
| RoomUserSeqEvent, ControlEvent | |
| ) | |
| # ========================================== | |
| # 1. Global State | |
| # ========================================== | |
| connected_clients = set() | |
| app_state = { | |
| "username": "", | |
| "connected": False, | |
| "connecting": False, | |
| "last_error": "", | |
| "comments": [], | |
| "followers": [], | |
| "gifts": {"count": 0, "diamonds": 0}, | |
| "likes": 0, | |
| "viewers": 0, | |
| "gift_alerts": [], | |
| } | |
| tiktok_client = None | |
| tiktok_task = None | |
| last_connect_attempt = 0 | |
| CONNECT_COOLDOWN = 30 | |
| MAX_COMMENTS = 20 | |
| MAX_FOLLOWERS = 5 | |
| MAX_GIFT_ALERTS = 2 # Only 2 cards max | |
| def log_print(msg): | |
| ts = datetime.now().strftime("%H:%M:%S") | |
| print(f"[{ts}] {msg}", flush=True) | |
| # ========================================== | |
| # 2. WebSocket Broadcast | |
| # ========================================== | |
| async def broadcast(msg): | |
| msg["time"] = datetime.now().strftime("%H:%M:%S") | |
| dead = set() | |
| for ws in connected_clients: | |
| try: | |
| await ws.send_json(msg) | |
| except Exception: | |
| dead.add(ws) | |
| connected_clients.difference_update(dead) | |
| # ========================================== | |
| # 3. Safe User Extraction | |
| # ========================================== | |
| def safe_extract_user(event): | |
| """Safely extract user info, avoiding nickName bug""" | |
| try: | |
| user = event.user | |
| if user: | |
| return user | |
| except Exception as e: | |
| log_print(f"USER_FALLBACK: {str(e)[:60]}") | |
| try: | |
| user_info = getattr(event, 'user_info', None) or getattr(event, 'userInfo', None) | |
| if user_info: | |
| class SimpleUser: | |
| pass | |
| su = SimpleUser() | |
| su.unique_id = getattr(user_info, 'uniqueId', None) or getattr(user_info, 'unique_id', None) or getattr(user_info, 'userId', None) or 'unknown' | |
| su.nickname = getattr(user_info, 'nickname', None) or getattr(user_info, 'nickName', None) or getattr(user_info, 'nick_name', None) or su.unique_id | |
| # Avatar - try ALL possible fields | |
| avatar = None | |
| for attr in ['avatarThumb', 'avatar_thumb', 'avatar', 'avatarMedium', 'avatar_medium', 'avatarLarger', 'avatar_larger']: | |
| avatar = getattr(user_info, attr, None) | |
| if avatar: | |
| break | |
| if avatar and hasattr(avatar, 'url'): | |
| su.avatar = avatar.url | |
| elif avatar and isinstance(avatar, str): | |
| su.avatar = avatar | |
| else: | |
| su.avatar = None | |
| return su | |
| except Exception as e2: | |
| log_print(f"USER_EXTRACT_FAILED: {str(e2)[:60]}") | |
| class DummyUser: | |
| unique_id = 'unknown' | |
| nickname = 'مستخدم' | |
| avatar = None | |
| return DummyUser() | |
| def get_user_avatar(user): | |
| """Extract real avatar URL from user object""" | |
| # Try all possible avatar fields | |
| for attr in ['avatar', 'avatar_thumb', 'avatarThumb', 'avatarMedium', 'avatar_medium', 'avatarLarger', 'avatar_larger']: | |
| val = getattr(user, attr, None) | |
| if val: | |
| if isinstance(val, str) and val.startswith('http'): | |
| return val | |
| if hasattr(val, 'url'): | |
| url = val.url | |
| if url and url.startswith('http'): | |
| return url | |
| if hasattr(val, 'urls') and val.urls: | |
| url = val.urls[0] | |
| if url and url.startswith('http'): | |
| return url | |
| # Fallback to generated | |
| uid = getattr(user, 'unique_id', None) or getattr(user, 'uniqueId', None) or 'user' | |
| return f"https://api.dicebear.com/7.x/avataaars/svg?seed={uid}" | |
| def get_user_name(user): | |
| return getattr(user, 'nickname', None) or getattr(user, 'nickName', None) or getattr(user, 'nick_name', None) or getattr(user, 'unique_id', None) or getattr(user, 'uniqueId', None) or 'مستخدم' | |
| # ========================================== | |
| # 4. Gift Icon URL Extractor | |
| # ========================================== | |
| def get_gift_icon_url(gift): | |
| """Extract real gift icon URL from TikTok CDN""" | |
| # Try icon field | |
| icon = getattr(gift, 'icon', None) | |
| if icon: | |
| if hasattr(icon, 'url_list') and icon.url_list: | |
| return icon.url_list[0] | |
| if hasattr(icon, 'url') and icon.url: | |
| return icon.url | |
| if hasattr(icon, 'urls') and icon.urls: | |
| return icon.urls[0] | |
| # Try image field | |
| image = getattr(gift, 'image', None) | |
| if image: | |
| if hasattr(image, 'url_list') and image.url_list: | |
| return image.url_list[0] | |
| if hasattr(image, 'url') and image.url: | |
| return image.url | |
| # Try gift_image field | |
| gift_image = getattr(gift, 'gift_image', None) or getattr(gift, 'giftImage', None) | |
| if gift_image: | |
| if hasattr(gift_image, 'url_list') and gift_image.url_list: | |
| return gift_image.url_list[0] | |
| if hasattr(gift_image, 'url') and gift_image.url: | |
| return gift_image.url | |
| return "" | |
| # ========================================== | |
| # 5. TikTok Handlers | |
| # ========================================== | |
| async def start_tiktok_stream(username): | |
| global tiktok_client, last_connect_attempt | |
| if not username.startswith('@'): | |
| username = '@' + username | |
| now = time.time() | |
| elapsed = now - last_connect_attempt | |
| if elapsed < CONNECT_COOLDOWN: | |
| wait = CONNECT_COOLDOWN - elapsed | |
| await broadcast({"type": "toast", "msg": f"⏳ انتظر {wait:.0f} ثانية", "color": "#f59e0b"}) | |
| await asyncio.sleep(wait) | |
| last_connect_attempt = time.time() | |
| app_state["connecting"] = True | |
| app_state["last_error"] = "" | |
| await broadcast({"type": "connecting", "msg": "جاري الاتصال بـ TikTok..."}) | |
| log_print(f"Connecting to TikTok: {username}") | |
| try: | |
| tiktok_client = TikTokLiveClient( | |
| unique_id=username, | |
| web_kwargs={}, | |
| ws_kwargs={"ping_interval": 20, "ping_timeout": 10} | |
| ) | |
| except Exception as e: | |
| app_state["connecting"] = False | |
| app_state["last_error"] = str(e) | |
| await broadcast({"type": "status", "connected": False}) | |
| await broadcast({"type": "toast", "msg": f"❌ خطأ: {str(e)[:100]}", "color": "#ef4444"}) | |
| return | |
| async def on_connect(e): | |
| app_state["connected"] = True | |
| app_state["connecting"] = False | |
| app_state["username"] = username | |
| log_print(f"CONNECTED: {username}") | |
| await broadcast({"type": "status", "connected": True, "username": username}) | |
| await broadcast({"type": "toast", "msg": f"✅ متصل ببث: {username}", "color": "#10b981"}) | |
| async def on_disconnect(e): | |
| app_state["connected"] = False | |
| app_state["connecting"] = False | |
| await broadcast({"type": "status", "connected": False}) | |
| await broadcast({"type": "toast", "msg": "❌ انقطع الاتصال", "color": "#ef4444"}) | |
| async def on_comment(e): | |
| try: | |
| user = safe_extract_user(e) | |
| comment_data = { | |
| "type": "comment", | |
| "name": get_user_name(user), | |
| "avatar": get_user_avatar(user), | |
| "text": e.comment, | |
| "id": getattr(user, 'unique_id', None) or getattr(user, 'uniqueId', None) or 'unknown' | |
| } | |
| app_state["comments"].insert(0, comment_data) | |
| if len(app_state["comments"]) > MAX_COMMENTS: | |
| app_state["comments"] = app_state["comments"][:MAX_COMMENTS] | |
| await broadcast({"type": "comment", "data": comment_data, "total": len(app_state["comments"])}) | |
| except Exception as ex: | |
| log_print(f"COMMENT_ERROR: {str(ex)[:80]}") | |
| async def on_gift(e): | |
| try: | |
| gift = e.gift | |
| user = safe_extract_user(e) | |
| diamonds = getattr(gift, 'diamond_count', 0) or getattr(gift, 'diamondCount', 0) or 0 | |
| repeat_count = getattr(gift, 'repeat_count', 1) or 1 | |
| gift_name = getattr(gift, 'name', None) or getattr(gift, 'giftName', None) or 'هدية' | |
| if hasattr(gift, 'repeat_end') and getattr(gift, 'repeat_end', 1): | |
| diamonds *= repeat_count | |
| icon_url = get_gift_icon_url(gift) | |
| app_state["gifts"]["count"] += 1 | |
| app_state["gifts"]["diamonds"] += int(diamonds) | |
| # Check if same user sending same gift - merge into existing alert | |
| merged = False | |
| for alert in app_state["gift_alerts"]: | |
| if alert["user"] == get_user_name(user) and alert["gift_name"] == gift_name: | |
| alert["repeat"] += repeat_count | |
| alert["diamonds"] += int(diamonds) | |
| alert["updated"] = time.time() | |
| merged = True | |
| break | |
| if not merged: | |
| alert = { | |
| "user": get_user_name(user), | |
| "gift_name": gift_name, | |
| "gift_icon": icon_url, | |
| "diamonds": int(diamonds), | |
| "repeat": repeat_count, | |
| "id": f"gift_{int(time.time()*1000)}", | |
| "updated": time.time() | |
| } | |
| app_state["gift_alerts"].insert(0, alert) | |
| if len(app_state["gift_alerts"]) > MAX_GIFT_ALERTS: | |
| app_state["gift_alerts"] = app_state["gift_alerts"][:MAX_GIFT_ALERTS] | |
| await broadcast({ | |
| "type": "gift", | |
| "name": gift_name, | |
| "icon": icon_url, | |
| "diamonds": int(diamonds), | |
| "repeat": repeat_count, | |
| "total_diamonds": app_state["gifts"]["diamonds"], | |
| "total_count": app_state["gifts"]["count"], | |
| "user": get_user_name(user), | |
| "alerts": app_state["gift_alerts"], | |
| "merged": merged | |
| }) | |
| except Exception as ex: | |
| log_print(f"GIFT_ERROR: {str(ex)[:80]}") | |
| async def on_follow(e): | |
| try: | |
| user = safe_extract_user(e) | |
| follower_data = { | |
| "name": get_user_name(user), | |
| "avatar": get_user_avatar(user), | |
| "id": getattr(user, 'unique_id', None) or getattr(user, 'uniqueId', None) or 'unknown' | |
| } | |
| app_state["followers"].insert(0, follower_data) | |
| if len(app_state["followers"]) > MAX_FOLLOWERS: | |
| app_state["followers"] = app_state["followers"][:MAX_FOLLOWERS] | |
| await broadcast({"type": "follower", "data": follower_data, "list": app_state["followers"]}) | |
| except Exception as ex: | |
| log_print(f"FOLLOW_ERROR: {str(ex)[:80]}") | |
| async def on_join(e): | |
| pass # Use RoomUserSeqEvent for accurate count | |
| async def on_like(e): | |
| try: | |
| # LikeEvent gives us the increment, not total | |
| count = getattr(e, 'like_count', 1) | |
| app_state["likes"] += int(count) | |
| await broadcast({"type": "like", "count": app_state["likes"]}) | |
| except Exception as ex: | |
| log_print(f"LIKE_ERROR: {str(ex)[:80]}") | |
| async def on_room_user_seq(e): | |
| """Accurate viewer count from TikTok""" | |
| try: | |
| viewer_count = getattr(e, 'viewer_count', None) or getattr(e, 'viewerCount', None) | |
| if viewer_count: | |
| app_state["viewers"] = int(viewer_count) | |
| await broadcast({"type": "viewers", "count": app_state["viewers"]}) | |
| # Some versions also send total likes in this event | |
| total_likes = getattr(e, 'total_likes', None) or getattr(e, 'totalLikes', None) | |
| if total_likes: | |
| app_state["likes"] = int(total_likes) | |
| await broadcast({"type": "like", "count": app_state["likes"]}) | |
| except Exception as ex: | |
| log_print(f"ROOM_SEQ_ERROR: {str(ex)[:80]}") | |
| async def on_control(e): | |
| try: | |
| action = getattr(e, 'action', None) | |
| if action == 3: | |
| await broadcast({"type": "toast", "msg": "🏁 انتهى البث", "color": "#f59e0b"}) | |
| await broadcast({"type": "status", "connected": False}) | |
| except Exception as ex: | |
| pass | |
| try: | |
| log_print(f"STARTING_CLIENT: {username}") | |
| await tiktok_client.start() | |
| except Exception as e: | |
| app_state["connected"] = False | |
| app_state["connecting"] = False | |
| err_str = str(e) | |
| log_print(f"CONNECTION_ERROR: {err_str}") | |
| if "RATE_LIMIT" in err_str or "rate_limit" in err_str or "Too many" in err_str: | |
| app_state["last_error"] = "RATE_LIMIT" | |
| await broadcast({"type": "status", "connected": False, "error": "rate_limit"}) | |
| await broadcast({"type": "toast", "msg": "⚠️ تقييد TikTok: انتظر 30 ثانية", "color": "#f59e0b"}) | |
| else: | |
| app_state["last_error"] = err_str | |
| await broadcast({"type": "status", "connected": False}) | |
| await broadcast({"type": "toast", "msg": f"⚠️ خطأ: {err_str[:120]}", "color": "#f59e0b"}) | |
| # ========================================== | |
| # 6. Web Server | |
| # ========================================== | |
| async def websocket_handler(request): | |
| ws = web.WebSocketResponse() | |
| await ws.prepare(request) | |
| connected_clients.add(ws) | |
| await ws.send_json({"type": "init", "state": app_state}) | |
| try: | |
| async for msg in ws: | |
| if msg.type == web.WSMsgType.TEXT: | |
| data = json.loads(msg.data) | |
| global tiktok_task, tiktok_client | |
| if data["action"] == "connect": | |
| if tiktok_task and not tiktok_task.done(): | |
| tiktok_task.cancel() | |
| if tiktok_client: | |
| try: await tiktok_client.disconnect() | |
| except: pass | |
| app_state["comments"] = [] | |
| app_state["followers"] = [] | |
| app_state["gifts"] = {"count": 0, "diamonds": 0} | |
| app_state["likes"] = 0 | |
| app_state["viewers"] = 0 | |
| app_state["gift_alerts"] = [] | |
| app_state["last_error"] = "" | |
| tiktok_task = asyncio.create_task(start_tiktok_stream(data["username"])) | |
| elif data["action"] == "disconnect": | |
| if tiktok_task and not tiktok_task.done(): | |
| tiktok_task.cancel() | |
| if tiktok_client: | |
| try: await tiktok_client.disconnect() | |
| except: pass | |
| app_state["connected"] = False | |
| app_state["connecting"] = False | |
| await broadcast({"type": "status", "connected": False}) | |
| except Exception as e: | |
| log_print(f"WS_ERROR: {str(e)[:80]}") | |
| finally: | |
| connected_clients.discard(ws) | |
| return ws | |
| async def html_handler(request): | |
| return web.Response(text=HTML_CONTENT, content_type='text/html') | |
| # ========================================== | |
| # 7. Mobile UI - Professional Design | |
| # ========================================== | |
| HTML_CONTENT = """ | |
| <!DOCTYPE html> | |
| <html lang="ar" dir="rtl"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>TikTok Live Dashboard</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700;900&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Cairo', sans-serif; background: #0a0a0f; overflow-x: hidden; } | |
| .glass { | |
| background: rgba(20, 20, 30, 0.7); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255,255,255,0.05); | |
| } | |
| .gradient-text { | |
| background: linear-gradient(135deg, #ff0050, #ff6b6b, #ffd93d); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .glow-pink { box-shadow: 0 0 20px rgba(255,0,80,0.3); } | |
| .glow-gold { box-shadow: 0 0 20px rgba(255,217,61,0.3); } | |
| @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes scaleIn { from { opacity: 0; transform: scale(0.8); } to { opacity: 1; transform: scale(1); } } | |
| @keyframes pulse-glow { 0%,100% { box-shadow: 0 0 15px rgba(255,0,80,0.4); } 50% { box-shadow: 0 0 30px rgba(255,0,80,0.7); } } | |
| @keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| @keyframes giftEnter { | |
| 0% { opacity: 0; transform: translateX(80px) scale(0.6); } | |
| 60% { opacity: 1; transform: translateX(-5px) scale(1.05); } | |
| 100% { opacity: 1; transform: translateX(0) scale(1); } | |
| } | |
| @keyframes giftExit { | |
| 0% { opacity: 1; transform: translateX(0) scale(1); } | |
| 100% { opacity: 0; transform: translateX(-80px) scale(0.6); } | |
| } | |
| @keyframes giftPulse { | |
| 0%,100% { filter: drop-shadow(0 0 8px rgba(255,215,0,0.6)); transform: scale(1); } | |
| 50% { filter: drop-shadow(0 0 20px rgba(255,215,0,1)); transform: scale(1.1); } | |
| } | |
| @keyframes countUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| .anim-slide-up { animation: slideUp 0.4s ease-out forwards; } | |
| .anim-scale { animation: scaleIn 0.3s ease-out forwards; } | |
| .pulse-glow { animation: pulse-glow 2s ease-in-out infinite; } | |
| .anim-float { animation: float 3s ease-in-out infinite; } | |
| .spin { animation: spin 1s linear infinite; } | |
| .gift-enter { animation: giftEnter 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; } | |
| .gift-exit { animation: giftExit 0.5s ease-in forwards; } | |
| .gift-pulse { animation: giftPulse 1s ease-in-out infinite; } | |
| .count-up { animation: countUp 0.3s ease-out; } | |
| .comment-enter { animation: slideUp 0.3s ease-out; } | |
| .follower-enter { animation: scaleIn 0.4s ease-out; } | |
| .hide-scrollbar::-webkit-scrollbar { display: none; } | |
| .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } | |
| .avatar-ring { position: relative; } | |
| .avatar-ring::after { | |
| content: ''; position: absolute; inset: -2px; border-radius: 50%; | |
| background: linear-gradient(135deg, #ff0050, #ff6b6b, #ffd93d); | |
| z-index: -1; animation: pulse-glow 2s ease-in-out infinite; | |
| } | |
| .loading-overlay { | |
| position: fixed; inset: 0; background: rgba(0,0,0,0.85); | |
| display: flex; align-items: center; justify-content: center; | |
| z-index: 100; opacity: 0; pointer-events: none; transition: opacity 0.3s; | |
| } | |
| .loading-overlay.active { opacity: 1; pointer-events: all; } | |
| .loader { | |
| width: 48px; height: 48px; border: 3px solid rgba(255,0,80,0.3); | |
| border-top-color: #ff0050; border-radius: 50%; animation: spin 0.8s linear infinite; | |
| } | |
| .debug-panel { | |
| background: rgba(0,0,0,0.85); border-top: 1px solid rgba(255,255,255,0.1); | |
| font-family: monospace; font-size: 10px; line-height: 1.4; | |
| color: #4ade80; max-height: 100px; overflow-y: auto; | |
| } | |
| .debug-panel .error { color: #f87171; } | |
| .debug-panel .warn { color: #fbbf24; } | |
| .debug-panel .info { color: #60a5fa; } | |
| /* Gift alert card - TikTok style */ | |
| .gift-alert-container { | |
| position: fixed; top: 70px; left: 8px; right: 8px; | |
| z-index: 60; pointer-events: none; | |
| display: flex; flex-direction: column; gap: 6px; | |
| } | |
| .gift-alert-card { | |
| background: linear-gradient(135deg, rgba(255,0,80,0.95), rgba(200,0,100,0.95)); | |
| border: 1px solid rgba(255,215,0,0.4); | |
| border-radius: 20px; padding: 10px 14px; | |
| display: flex; align-items: center; gap: 10px; | |
| box-shadow: 0 4px 20px rgba(255,0,80,0.5), inset 0 1px 0 rgba(255,255,255,0.1); | |
| backdrop-filter: blur(8px); | |
| } | |
| .gift-alert-card .gift-icon-wrap { | |
| width: 44px; height: 44px; border-radius: 12px; | |
| background: rgba(255,255,255,0.15); | |
| display: flex; align-items: center; justify-content: center; | |
| overflow: hidden; flex-shrink: 0; | |
| } | |
| .gift-alert-card .gift-icon-wrap img { | |
| width: 36px; height: 36px; object-fit: contain; | |
| } | |
| .gift-alert-card .gift-count-badge { | |
| position: absolute; top: -6px; right: -6px; | |
| background: linear-gradient(135deg, #ffd700, #ffaa00); | |
| color: #000; font-size: 11px; font-weight: 900; | |
| padding: 2px 8px; border-radius: 10px; | |
| box-shadow: 0 2px 8px rgba(255,215,0,0.5); | |
| } | |
| </style> | |
| </head> | |
| <body class="min-h-screen text-white"> | |
| <!-- Loading Overlay --> | |
| <div id="loading-overlay" class="loading-overlay"> | |
| <div class="flex flex-col items-center gap-3"> | |
| <div class="loader"></div> | |
| <p id="loading-text" class="text-sm font-bold text-pink-400">جاري الاتصال...</p> | |
| <p class="text-xs text-gray-500">قد يستغرق 30 ثانية</p> | |
| </div> | |
| </div> | |
| <!-- Gift Alert Container (max 2 cards, queue system) --> | |
| <div id="gift-alerts" class="gift-alert-container"></div> | |
| <div class="max-w-md mx-auto min-h-screen flex flex-col relative pb-[120px]"> | |
| <!-- Background Effects --> | |
| <div class="fixed inset-0 pointer-events-none overflow-hidden"> | |
| <div class="absolute top-[-10%] right-[-20%] w-72 h-72 bg-pink-600/20 rounded-full blur-3xl anim-float"></div> | |
| <div class="absolute bottom-[20%] left-[-10%] w-64 h-64 bg-yellow-500/10 rounded-full blur-3xl anim-float" style="animation-delay: 1s;"></div> | |
| </div> | |
| <!-- Header --> | |
| <header class="relative z-10 p-4 pt-6"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <div class="flex items-center gap-2"> | |
| <div id="status-dot" class="w-3 h-3 rounded-full bg-red-500 transition-all duration-300"></div> | |
| <span id="status-text" class="text-xs text-gray-400 font-semibold tracking-wider">غير متصل</span> | |
| </div> | |
| <div class="text-xs text-gray-500" id="viewer-count">👁 0 مشاهد</div> | |
| </div> | |
| <h1 class="text-2xl font-black gradient-text mb-1">TikTok Live</h1> | |
| <p class="text-xs text-gray-500 mb-4">لوحة التحكم التفاعلية</p> | |
| <!-- Connection Input --> | |
| <div class="glass rounded-2xl p-3 flex gap-2"> | |
| <input type="text" id="user-input" placeholder="أدخل @username..." | |
| class="flex-1 bg-transparent text-sm text-white placeholder-gray-500 outline-none px-2" | |
| dir="ltr" autocomplete="off"> | |
| <button onclick="connect()" id="btn-connect" | |
| class="bg-gradient-to-r from-pink-600 to-rose-500 px-4 py-2 rounded-xl text-xs font-bold | |
| hover:scale-105 transition-transform active:scale-95"> | |
| اتصال | |
| </button> | |
| <button onclick="disconnect()" | |
| class="bg-gray-700 px-3 py-2 rounded-xl text-xs font-bold hover:bg-gray-600 transition-colors"> | |
| ✕ | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Stats Row --> | |
| <section class="relative z-10 px-4 grid grid-cols-3 gap-3 mb-4"> | |
| <div class="glass rounded-2xl p-3 text-center glow-pink"> | |
| <div class="text-lg font-black text-pink-400 count-up" id="gift-count">0</div> | |
| <div class="text-[10px] text-gray-400">الهدايا</div> | |
| </div> | |
| <div class="glass rounded-2xl p-3 text-center glow-gold"> | |
| <div class="text-lg font-black text-yellow-400 count-up" id="diamond-count">0</div> | |
| <div class="text-[10px] text-gray-400">الماس 💎</div> | |
| </div> | |
| <div class="glass rounded-2xl p-3 text-center"> | |
| <div class="text-lg font-black text-rose-400 count-up" id="like-count">0</div> | |
| <div class="text-[10px] text-gray-400">إعجابات ❤️</div> | |
| </div> | |
| </section> | |
| <!-- Last Followers --> | |
| <section class="relative z-10 px-4 mb-4"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h2 class="text-xs font-bold text-gray-400 uppercase tracking-wider">آخر المتابعين</h2> | |
| <span class="text-[10px] text-pink-500 font-bold" id="follower-badge">0</span> | |
| </div> | |
| <div class="glass rounded-2xl p-3"> | |
| <div id="followers-list" class="flex gap-3 overflow-x-auto hide-scrollbar pb-1"> | |
| <div class="text-xs text-gray-600 text-center w-full py-4">لا يوجد متابعين جدد</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Live Chat --> | |
| <section class="relative z-10 px-4 flex-1 flex flex-col min-h-0"> | |
| <div class="flex items-center justify-between mb-2"> | |
| <h2 class="text-xs font-bold text-gray-400 uppercase tracking-wider">التعليقات المباشرة</h2> | |
| <span class="text-[10px] text-gray-500" id="comment-count">0</span> | |
| </div> | |
| <div class="glass rounded-2xl flex-1 overflow-hidden flex flex-col min-h-[200px]"> | |
| <div id="chat-list" class="flex-1 overflow-y-auto p-3 space-y-3 hide-scrollbar"> | |
| <div class="text-center text-gray-600 text-xs py-8">انتظر الاتصال بالبث...</div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Toast --> | |
| <div id="toast" class="fixed top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 rounded-xl text-xs font-bold | |
| text-white opacity-0 transition-opacity duration-300 pointer-events-none max-w-[90%] text-center"> | |
| </div> | |
| </div> | |
| <!-- Debug Panel --> | |
| <div id="debug-panel" class="debug-panel fixed bottom-0 left-0 right-0 z-50 p-2"> | |
| <div class="flex items-center justify-between mb-1"> | |
| <span class="text-[10px] text-gray-400">🔧 Debug</span> | |
| <button onclick="clearDebug()" class="text-[10px] text-gray-500">مسح</button> | |
| </div> | |
| <div id="debug-content" class="space-y-0.5"></div> | |
| </div> | |
| <script> | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = protocol + '//' + window.location.host + '/ws'; | |
| let ws = null; | |
| let reconnectTimer = null; | |
| let followers = []; | |
| let comments = []; | |
| let activeGiftCards = {}; // Track active gift cards by ID | |
| let giftQueue = []; // Queue for gifts when 2 cards are full | |
| const chatList = document.getElementById('chat-list'); | |
| const followersList = document.getElementById('followers-list'); | |
| const statusDot = document.getElementById('status-dot'); | |
| const statusText = document.getElementById('status-text'); | |
| const toast = document.getElementById('toast'); | |
| const loadingOverlay = document.getElementById('loading-overlay'); | |
| const loadingText = document.getElementById('loading-text'); | |
| const debugContent = document.getElementById('debug-content'); | |
| const giftAlertsContainer = document.getElementById('gift-alerts'); | |
| function addDebug(msg, type='info') { | |
| const time = new Date().toLocaleTimeString('en-US', {hour12:false}); | |
| const line = document.createElement('div'); | |
| line.className = type; | |
| line.textContent = `[${time}] ${msg}`; | |
| debugContent.appendChild(line); | |
| debugContent.scrollTop = debugContent.scrollHeight; | |
| while (debugContent.children.length > 40) { | |
| debugContent.removeChild(debugContent.firstChild); | |
| } | |
| } | |
| function clearDebug() { debugContent.innerHTML = ''; } | |
| function connectWS() { | |
| if (ws && ws.readyState === WebSocket.OPEN) return; | |
| addDebug('WS Connecting...', 'info'); | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = () => { | |
| addDebug('WS Connected!', 'info'); | |
| showToast('🟢 متصل بالسيرفر', '#10b981'); | |
| }; | |
| ws.onmessage = (e) => { | |
| try { handleMessage(JSON.parse(e.data)); } | |
| catch (err) { addDebug('PARSE_ERR: ' + err.message, 'error'); } | |
| }; | |
| ws.onclose = () => { | |
| addDebug('WS Closed. Retry 3s...', 'warn'); | |
| statusDot.className = 'w-3 h-3 rounded-full bg-yellow-500'; | |
| statusText.textContent = 'إعادة الاتصال...'; | |
| if (!reconnectTimer) { | |
| reconnectTimer = setTimeout(() => { reconnectTimer = null; connectWS(); }, 3000); | |
| } | |
| }; | |
| ws.onerror = (err) => { addDebug('WS ERROR', 'error'); }; | |
| } | |
| // Gift card system - max 2 cards, queue, merge same user+same gift | |
| function updateOrCreateGiftCard(alert) { | |
| const existingId = Object.keys(activeGiftCards).find(id => { | |
| const card = activeGiftCards[id]; | |
| return card.user === alert.user && card.gift_name === alert.gift_name; | |
| }); | |
| if (existingId) { | |
| // Update existing card | |
| const card = activeGiftCards[existingId]; | |
| card.repeat = alert.repeat; | |
| card.diamonds = alert.diamonds; | |
| card.element.querySelector('.gift-count').textContent = 'x' + card.repeat; | |
| card.element.querySelector('.gift-diamonds').textContent = card.diamonds + ' 💎'; | |
| // Reset exit timer | |
| if (card.timeout) clearTimeout(card.timeout); | |
| card.timeout = setTimeout(() => removeGiftCard(existingId), 4000); | |
| return; | |
| } | |
| // Check if we have space (max 2) | |
| if (Object.keys(activeGiftCards).length >= 2) { | |
| giftQueue.push(alert); | |
| return; | |
| } | |
| createGiftCard(alert); | |
| } | |
| function createGiftCard(alert) { | |
| const card = document.createElement('div'); | |
| card.className = 'gift-alert-card gift-enter'; | |
| card.id = alert.id; | |
| const hasIcon = alert.gift_icon && alert.gift_icon.startsWith('http'); | |
| const iconHtml = hasIcon | |
| ? `<img src="${alert.gift_icon}" class="gift-pulse" onerror="this.style.display='none'; this.parentElement.innerHTML='🎁';">` | |
| : '🎁'; | |
| card.innerHTML = ` | |
| <div class="gift-icon-wrap relative"> | |
| ${iconHtml} | |
| <span class="gift-count-badge gift-count">x${alert.repeat}</span> | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="text-xs font-bold text-white truncate">${alert.user}</div> | |
| <div class="text-[10px] text-pink-200">${alert.gift_name}</div> | |
| <div class="text-[10px] text-yellow-300 gift-diamonds">${alert.diamonds} 💎</div> | |
| </div> | |
| `; | |
| giftAlertsContainer.appendChild(card); | |
| activeGiftCards[alert.id] = { | |
| element: card, | |
| user: alert.user, | |
| gift_name: alert.gift_name, | |
| repeat: alert.repeat, | |
| diamonds: alert.diamonds, | |
| timeout: setTimeout(() => removeGiftCard(alert.id), 4000) | |
| }; | |
| } | |
| function removeGiftCard(id) { | |
| const card = activeGiftCards[id]; | |
| if (!card) return; | |
| card.element.classList.remove('gift-enter'); | |
| card.element.classList.add('gift-exit'); | |
| setTimeout(() => { | |
| if (card.element.parentNode) card.element.parentNode.removeChild(card.element); | |
| delete activeGiftCards[id]; | |
| // Process queue | |
| if (giftQueue.length > 0) { | |
| const next = giftQueue.shift(); | |
| createGiftCard(next); | |
| } | |
| }, 500); | |
| } | |
| function handleMessage(d) { | |
| if (d.type === 'init') { | |
| followers = d.state.followers || []; | |
| comments = d.state.comments || []; | |
| updateCount('gift-count', d.state.gifts?.count || 0); | |
| updateCount('diamond-count', d.state.gifts?.diamonds || 0); | |
| updateCount('like-count', d.state.likes || 0); | |
| document.getElementById('viewer-count').textContent = '👁 ' + (d.state.viewers || 0) + ' مشاهد'; | |
| if (d.state.connected) { | |
| statusDot.className = 'w-3 h-3 rounded-full bg-green-500 pulse-glow'; | |
| statusText.textContent = 'متصل'; | |
| statusText.className = 'text-xs text-green-400 font-semibold'; | |
| } | |
| renderFollowers(); | |
| renderComments(); | |
| } | |
| else if (d.type === 'connecting') { | |
| loadingText.textContent = d.msg || 'جاري الاتصال...'; | |
| loadingOverlay.classList.add('active'); | |
| } | |
| else if (d.type === 'status') { | |
| loadingOverlay.classList.remove('active'); | |
| if (d.connected) { | |
| statusDot.className = 'w-3 h-3 rounded-full bg-green-500 pulse-glow'; | |
| statusText.textContent = 'متصل'; | |
| statusText.className = 'text-xs text-green-400 font-semibold'; | |
| showToast('✅ متصل', '#10b981'); | |
| } else { | |
| statusDot.className = 'w-3 h-3 rounded-full bg-red-500'; | |
| statusText.textContent = 'غير متصل'; | |
| statusText.className = 'text-xs text-gray-400 font-semibold'; | |
| } | |
| } | |
| else if (d.type === 'toast') { | |
| showToast(d.msg, d.color); | |
| if (d.color === '#f59e0b' || d.color === '#ef4444') loadingOverlay.classList.remove('active'); | |
| } | |
| else if (d.type === 'comment') { | |
| comments.unshift(d.data); | |
| if (comments.length > 20) comments.pop(); | |
| document.getElementById('comment-count').textContent = d.total; | |
| renderComments(); | |
| } | |
| else if (d.type === 'follower') { | |
| followers = d.list; | |
| document.getElementById('follower-badge').textContent = followers.length; | |
| renderFollowers(); | |
| } | |
| else if (d.type === 'gift') { | |
| updateCount('gift-count', d.total_count); | |
| updateCount('diamond-count', d.total_diamonds); | |
| // Update gift cards from server alerts | |
| if (d.alerts) { | |
| d.alerts.forEach(alert => updateOrCreateGiftCard(alert)); | |
| } | |
| } | |
| else if (d.type === 'like') { | |
| updateCount('like-count', d.count); | |
| } | |
| else if (d.type === 'viewers') { | |
| document.getElementById('viewer-count').textContent = '👁 ' + d.count + ' مشاهد'; | |
| } | |
| } | |
| function updateCount(id, value) { | |
| const el = document.getElementById(id); | |
| el.textContent = value.toLocaleString('en-US'); | |
| el.classList.remove('count-up'); | |
| void el.offsetWidth; // trigger reflow | |
| el.classList.add('count-up'); | |
| } | |
| function showToast(msg, color) { | |
| toast.textContent = msg; | |
| toast.style.background = color; | |
| toast.style.opacity = '1'; | |
| setTimeout(() => { toast.style.opacity = '0'; }, 3000); | |
| } | |
| function renderFollowers() { | |
| if (followers.length === 0) { | |
| followersList.innerHTML = '<div class="text-xs text-gray-600 text-center w-full py-4">لا يوجد متابعين جدد</div>'; | |
| return; | |
| } | |
| followersList.innerHTML = followers.map((f, i) => ` | |
| <div class="flex-shrink-0 flex flex-col items-center gap-1 follower-enter" style="animation-delay: ${i*0.1}s"> | |
| <div class="avatar-ring w-12 h-12 rounded-full overflow-hidden"> | |
| <img src="${f.avatar}" class="w-full h-full object-cover" loading="lazy" | |
| onerror="this.src='https://api.dicebear.com/7.x/avataaars/svg?seed=${f.id}'"> | |
| </div> | |
| <span class="text-[10px] text-gray-300 truncate max-w-[60px]">${f.name}</span> | |
| </div> | |
| `).join(''); | |
| } | |
| function renderComments() { | |
| if (comments.length === 0) { | |
| chatList.innerHTML = '<div class="text-center text-gray-600 text-xs py-8">انتظر الاتصال بالبث...</div>'; | |
| return; | |
| } | |
| chatList.innerHTML = comments.map((c, i) => ` | |
| <div class="flex gap-2 items-start comment-enter ${i===0 ? 'bg-white/5 rounded-xl p-2 -mx-1' : ''}"> | |
| <img src="${c.avatar}" class="w-8 h-8 rounded-full object-cover flex-shrink-0 border border-white/10" loading="lazy" | |
| onerror="this.src='https://api.dicebear.com/7.x/avataaars/svg?seed=${c.id}'"> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex items-center gap-1 mb-0.5"> | |
| <span class="text-xs font-bold text-pink-400 truncate">${c.name}</span> | |
| <span class="text-[10px] text-gray-600">${c.time || ''}</span> | |
| </div> | |
| <p class="text-sm text-gray-200 leading-relaxed break-words">${c.text}</p> | |
| </div> | |
| </div> | |
| `).join(''); | |
| chatList.scrollTop = 0; | |
| } | |
| function connect() { | |
| const u = document.getElementById('user-input').value.trim(); | |
| if (!u) return showToast('أدخل اسم المستخدم أولاً', '#ef4444'); | |
| if (!ws || ws.readyState !== WebSocket.OPEN) { | |
| showToast('⚠️ جاري إعادة الاتصال...', '#f59e0b'); | |
| connectWS(); | |
| setTimeout(() => connect(), 1000); | |
| return; | |
| } | |
| loadingText.textContent = 'جاري الاتصال بـ TikTok...'; | |
| loadingOverlay.classList.add('active'); | |
| ws.send(JSON.stringify({action: 'connect', username: u})); | |
| } | |
| function disconnect() { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({action: 'disconnect'})); | |
| } | |
| comments = []; followers = []; | |
| activeGiftCards = {}; giftQueue = []; | |
| giftAlertsContainer.innerHTML = ''; | |
| renderComments(); renderFollowers(); | |
| updateCount('gift-count', 0); updateCount('diamond-count', 0); updateCount('like-count', 0); | |
| document.getElementById('comment-count').textContent = '0'; | |
| document.getElementById('follower-badge').textContent = '0'; | |
| document.getElementById('viewer-count').textContent = '👁 0 مشاهد'; | |
| } | |
| document.getElementById('user-input').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') connect(); | |
| }); | |
| addDebug('Loaded. WS: ' + wsUrl, 'info'); | |
| connectWS(); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| async def init_app(): | |
| app = web.Application() | |
| app.router.add_get('/', html_handler) | |
| app.router.add_get('/ws', websocket_handler) | |
| return app | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| log_print(f"\n🚀 TikTok Live Dashboard running on port {port}") | |
| log_print(f"🌍 Open: http://0.0.0.0:{port}\n") | |
| web.run_app(init_app(), host='0.0.0.0', port=port) |