U / app.py
Poorpoor6976's picture
Update app.py
b574b1f verified
Raw
History Blame Contribute Delete
39.6 kB
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
@tiktok_client.on(ConnectEvent)
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"})
@tiktok_client.on(DisconnectEvent)
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"})
@tiktok_client.on(CommentEvent)
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]}")
@tiktok_client.on(GiftEvent)
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]}")
@tiktok_client.on(FollowEvent)
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]}")
@tiktok_client.on(JoinEvent)
async def on_join(e):
pass # Use RoomUserSeqEvent for accurate count
@tiktok_client.on(LikeEvent)
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]}")
@tiktok_client.on(RoomUserSeqEvent)
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]}")
@tiktok_client.on(ControlEvent)
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)