from __future__ import annotations from typing import Any, Callable import gradio as gr class NeonToast(gr.HTML): """Custom HTML toast that reacts to value changes through Gradio props.""" def __init__(self, value: str = "", **kwargs: Any) -> None: """Create the toast component. Args: value: Initial toast text. **kwargs: Additional arguments forwarded to `gr.HTML`. """ html_template = """
${value}
""" css_template = """ #toast-container { position: fixed; top: -100px; left: 50%; transform: translateX(-50%); background-color: #161122 !important; border: 2px solid #ff0055 !important; color: #ffffff !important; padding: 12px 25px; border-radius: 8px; font-weight: bold; box-shadow: 0 0 20px rgba(255, 0, 85, 0.6); z-index: 10000; transition: top 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); font-size: 14px; letter-spacing: 0.5px; display: flex; align-items: center; gap: 10px; pointer-events: none; } #toast-container.toast-show { top: 20px; } #toast-msg { color: #ffffff !important; } """ js_on_load = """ let toastTimeout; let toastShowTimer; let toastToken = 0; window.dodClearToast = () => { toastToken += 1; clearTimeout(toastTimeout); clearTimeout(toastShowTimer); const toastEl = element.querySelector('#toast-container'); if (!toastEl) return; toastEl.querySelector('#toast-msg').innerText = ""; toastEl.classList.remove('toast-show'); }; watch('value', () => { const toastEl = element.querySelector('#toast-container'); if (!toastEl) return; const message = (typeof props.value === "string") ? props.value.trim() : ""; if (!message) { window.dodClearToast(); return; } const currentToken = toastToken + 1; toastToken = currentToken; clearTimeout(toastTimeout); clearTimeout(toastShowTimer); toastEl.classList.remove('toast-show'); toastShowTimer = setTimeout(() => { if (toastToken !== currentToken) return; toastEl.querySelector('#toast-msg').innerText = props.value; toastEl.classList.add('toast-show'); toastTimeout = setTimeout(() => { if (toastToken !== currentToken) return; window.dodClearToast(); }, 5000); }, 50); }); """ super().__init__(value=value, html_template=html_template, css_template=css_template, js_on_load=js_on_load, **kwargs) class Board(gr.HTML): """Custom reactive DOD UNO board component backed by Gradio HTML templates.""" def __init__( self, value: dict[str, Any] | None = None, server_functions: list[Callable[..., Any]] | None = None, **kwargs: Any, ) -> None: """Create a board bound to the DOD UNO custom HTML/CSS/JS templates. Args: value: Initial board state payload. server_functions: Python functions exposed to the browser-side component. **kwargs: Additional arguments forwarded to `gr.HTML`. """ css_template = """ .game-layout { display: flex !important; gap: 15px !important; width: 100% !important; max-width: 1400px !important; margin: 0 auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important; color: #ffffff !important; height: 750px !important; overflow: hidden; background-color: #0f1117 !important; padding: 15px !important; border-radius: 12px !important; box-sizing: border-box !important; } .spectator-mode .card, .spectator-mode button, .spectator-mode .draw-pile-btn, .spectator-mode .color-btn { pointer-events: none !important; user-select: none !important; } .spectator-mode .audio-toggle-btn { pointer-events: auto !important; user-select: auto !important; } .left-col { flex: 3 !important; display: flex !important; flex-direction: column !important; gap: 8px !important; height: 100%; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: #44345d transparent; padding-top: 2px !important;} .left-col::-webkit-scrollbar { width: 4px; } .left-col::-webkit-scrollbar-track { background: transparent; } .left-col::-webkit-scrollbar-thumb { background: #44345d; border-radius: 4px; } .right-col { flex: 1 !important; display: flex !important; flex-direction: column !important; background-color: #0b0812 !important; border-radius: 12px !important; border: 1.5px solid #44345d !important; padding: 15px !important; box-sizing: border-box !important; height: 100%; } .right-col h3 { padding-right: 46px !important; min-height: 40px !important; box-sizing: border-box !important; } .crisis-box { background-color: #1a1525 !important; border: 2px solid #ff0055 !important; border-radius: 10px !important; padding: 10px 15px !important; box-shadow: 0 4px 15px rgba(255, 0, 85, 0.25) !important; color: #ffffff !important; width: 100% !important; box-sizing: border-box !important;} .alert-title { color: #ff0055 !important; font-weight: bold !important; text-shadow: 0 0 5px #ff0055 !important; } .status-container { margin-top: 4px !important; } .bar-label { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: #ffffff !important; font-weight: bold !important; } .progress-bar { background-color: #2a2235 !important; border-radius: 6px !important; height: 12px !important; overflow: hidden !important; border: 1px solid rgba(255, 255, 255, 0.15) !important; } .progress-fill { height: 100% !important; transition: width 0.4s ease !important; } .res-fill { background-color: #2ecc71 !important; box-shadow: 0 0 10px #2ecc71 !important; } .panic-fill { background-color: #e74c3c !important; box-shadow: 0 0 10px #e74c3c !important; } .board-container { display: flex !important; flex-direction: column !important; gap: 8px !important; height: 100%;} .opponents-row { display: flex !important; gap: 12px !important; justify-content: center !important; margin-bottom: 2px !important; flex-wrap: wrap !important; padding: 5px !important;} .opponent-badge { position: relative; background: rgba(0,0,0,0.4) !important; border-radius: 10px !important; padding: 6px 12px !important; text-align: center !important; color: white !important; border: 2px solid #44345d !important; transition: all 0.3s !important; min-width: 100px !important;} .opponent-badge.active { border-color: #00f3ff !important; animation: pulse-glow 2.5s infinite ease-in-out !important; transform: scale(1.03) !important; background: rgba(0,243,255,0.1) !important; } .opp-name { font-weight: bold !important; font-size: 13px !important; margin-bottom: 3px !important; color: #cbd5e0 !important; } .opp-cards { font-size: 16px !important; font-weight: bold !important; color: #2ecc71 !important; display: flex !important; align-items: center !important; justify-content: center !important; gap: 5px !important; } .opp-avatar { width: 24px !important; height: 24px !important; border-radius: 50% !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; overflow: hidden !important; background: rgba(10, 14, 28, 0.9) !important; border: 1px solid rgba(255, 255, 255, 0.25) !important; box-shadow: 0 1px 4px rgba(0,0,0,0.35) !important; flex-shrink: 0 !important; } .opp-avatar-img { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block !important; } .opp-avatar-default { width: 15px !important; height: 15px !important; fill: #cbd5e0 !important; } .opp-risk { position: absolute; top: -8px; right: -8px; font-size: 10px !important; font-weight: bold !important; color: #fff !important; background: #ff0055; padding: 1px 5px; border-radius: 8px; animation: blink 1.5s infinite !important; } .my-hand-panel { margin-top: 2px !important; } .cards-list-horizontal { display: flex !important; overflow-x: auto !important; gap: 10px !important; padding: 5px !important; scrollbar-width: thin !important; scrollbar-color: #44345d transparent !important; background-color: rgba(0,0,0,0.2) !important; border-radius: 8px !important; border: 1px solid rgba(255, 255, 255, 0.05) !important; min-height: 145px; align-items: center;} .cards-list-horizontal::-webkit-scrollbar { height: 6px; } .cards-list-horizontal::-webkit-scrollbar-thumb { background: #44345d; border-radius: 4px; } .queue-banner { background: rgba(241, 196, 15, 0.2) !important; border: 2px solid #f1c40f !important; color: #f1c40f !important; padding: 10px !important; text-align: center !important; font-weight: bold !important; border-radius: 8px !important; font-size: 16px !important; margin-top: 10px !important;} .restart-banner { background: rgba(0, 243, 255, 0.2) !important; border: 2px solid #00f3ff !important; color: #00f3ff !important; padding: 10px !important; text-align: center !important; font-weight: bold !important; border-radius: 8px !important; font-size: 16px !important; margin-bottom: 10px !important;} .board-toolbar { position: absolute !important; top: 8px !important; right: 8px !important; z-index: 50 !important; display: flex !important; gap: 6px !important; } .audio-toggle-btn { width: 34px !important; height: 34px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; border-radius: 50% !important; border: 1px solid rgba(0, 243, 255, 0.75) !important; background: rgba(7, 20, 38, 0.82) !important; color: #00f3ff !important; font-size: 17px !important; cursor: pointer !important; box-shadow: 0 0 12px rgba(0, 243, 255, 0.25) !important; transition: transform 0.15s ease, background 0.15s ease, color 0.15s ease !important; } .audio-toggle-btn:hover { transform: translateY(-1px) scale(1.04) !important; background: rgba(0, 243, 255, 0.18) !important; color: #ffffff !important; } .audio-toggle-btn.is-muted { border-color: rgba(255, 0, 85, 0.85) !important; color: #ff6b9a !important; box-shadow: 0 0 12px rgba(255, 0, 85, 0.25) !important; } .table-board { display: flex !important; width: 100% !important; height: 200px !important; justify-content: center !important; gap: 40px !important; align-items: center !important; background: radial-gradient(circle, #1a365d 0%, #071426 100%) !important; border: 1.5px solid rgba(255, 255, 255, 0.08) !important; border-radius: 12px !important; padding: 20px !important; position: relative !important; box-sizing: border-box !important; box-shadow: inset 0 0 20px rgba(0,0,0,0.5) !important; margin-top: 4px !important;} .picker-box { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; background-color: rgba(7, 20, 38, 0.98) !important; border-radius: 12px !important; flex-direction: column !important; justify-content: center !important; align-items: center !important; z-index: 9999 !important; border: 2px solid #00f3ff !important; } .color-btns { display: flex !important; gap: 10px !important; margin-top: 10px !important; align-items: center !important; justify-content: center !important; } .color-btn { display: inline-flex !important; align-items: center !important; justify-content: center !important; height: 38px !important; padding: 0 15px !important; border: 1px solid #cbd5e0 !important; border-radius: 6px !important; font-weight: bold !important; cursor: pointer !important; color: #ffffff !important; box-sizing: border-box !important; line-height: 1 !important; margin: 0 !important; align-self: center !important; vertical-align: middle !important; } .draw-pile-btn { background-color: #f4f4f9 !important; border: 4px solid #ffffff !important; border-radius: 10px !important; display: flex !important; flex-direction: column !important; justify-content: center !important; align-items: center !important; cursor: pointer !important; color: #111115 !important; font-weight: bold !important; text-align: center !important; box-shadow: 2px 2px 0px #ffffff, 4px 4px 0px #1a1525, 6px 6px 0px #ffffff, 8px 8px 15px rgba(0,0,0,0.6), inset 0 0 20px rgba(0,0,0,0.12) !important; margin-right: 8px !important; margin-bottom: 8px !important; overflow: hidden !important; } .draw-pile-btn.card-large { padding: 0 !important; } .draw-pile-btn:hover { transform: translateY(-5px) !important; box-shadow: 2px 7px 0px #ffffff, 4px 9px 0px #1a1525, 6px 11px 0px #ffffff, 8px 13px 20px rgba(0,0,0,0.8) !important; } .draw-pile-card-face { width: 100% !important; height: 100% !important; display: flex !important; align-items: center !important; justify-content: center !important; background: #f4f4f9 !important; border-radius: 6px !important; position: relative !important; overflow: hidden !important; } .draw-pile-logo { width: 100% !important; height: 100% !important; object-fit: cover !important; display: block !important; } .draw-pile-label { position: absolute !important; left: 50% !important; bottom: 8px !important; transform: translateX(-50%) !important; max-width: calc(100% - 12px) !important; padding: 3px 7px !important; border-radius: 5px !important; background: rgba(244, 244, 249, 0.86) !important; color: #111115 !important; font-size: 11px !important; font-weight: 900 !important; letter-spacing: 0.5px !important; line-height: 1 !important; text-transform: uppercase !important; box-shadow: 0 2px 6px rgba(0,0,0,0.25) !important; white-space: nowrap !important; z-index: 1 !important; } .action-btn { padding: 8px 12px !important; border: 1px solid rgba(255, 255, 255, 0.2) !important; border-radius: 6px !important; cursor: pointer !important; font-weight: bold !important; font-size: 11px !important; width: 120px !important; margin-top: 8px !important; font-family: inherit !important; text-align: center; } .pass-btn { background-color: #00f3ff !important; color: black !important; box-shadow: 0 0 10px rgba(0, 243, 255, 0.5) !important; border: none !important; } .shout-btn { background-color: #ff0055 !important; color: white !important; box-shadow: 0 0 10px rgba(255, 0, 85, 0.5) !important; border: none !important; animation: blink 1.5s infinite; } .btn-leave { background-color: transparent !important; color: #ff0055 !important; border: 1px solid #ff0055 !important; padding: 4px 10px !important; border-radius: 6px !important; font-size: 12px !important; cursor: pointer !important; font-weight: bold !important; transition: all 0.2s !important; } .btn-leave:hover { background-color: #ff0055 !important; color: #fff !important; } .card { flex-shrink: 0; position: relative !important; width: 100px !important; height: 140px !important; border-radius: 8px !important; background-color: #ffffff !important; display: inline-flex !important; flex-direction: column !important; justify-content: flex-start !important; gap: 5px !important; padding: 8px 6px !important; box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important; cursor: pointer !important; transition: transform 0.2s, box-shadow 0.2s !important; box-sizing: border-box !important; margin: 4px !important; border: 3.5px solid #ffffff !important; } .card:hover { transform: translateY(-8px) scale(1.04) !important; box-shadow: 0 8px 16px rgba(0,0,0,0.5) !important; z-index: 10 !important; position: relative; } .card-badge { position: absolute !important; top: 4px !important; left: 4px !important; background-color: rgba(0,0,0,0.65) !important; color: white !important; padding: 2px 5px !important; border-radius: 4px !important; font-weight: bold !important; font-size: 10px !important; border: 1px solid rgba(255,255,255,0.4) !important; box-shadow: 0 2px 4px rgba(0,0,0,0.5) !important; } .card-large { width: 105px !important; height: 150px !important; border-radius: 10px !important; padding: 8px 6px !important; } .card-large .card-diamond { width: 44px !important; height: 44px !important; margin: 5px auto 3px auto !important; flex-shrink: 0 !important; display: flex !important; justify-content: center !important; align-items: center !important;} .card-large .card-symbol { font-size: 22px !important; } .card-large .card-title-text { font-size: 10px !important; margin-top: 2px !important; } .card-large .card-stats { font-size: 8px !important; } .card-bg-green { background-color: #2ecc71 !important; } .card-bg-blue { background-color: #3498db !important; } .card-bg-red { background-color: #e74c3c !important; } .card-bg-yellow { background-color: #f1c40f !important; } .card-bg-wild { background-color: #111115 !important; } .card-back { background-color: #f4f4f9 !important; border: 4px solid #ffffff !important; box-shadow: inset 0 0 20px rgba(0,0,0,0.15), 0 4px 8px rgba(0,0,0,0.3) !important; justify-content: center !important; align-items: center !important; padding: 0 !important; } .card-back-inner { border-radius: 4px !important; display: flex !important; justify-content: center !important; align-items: center !important; box-shadow: inset 0 0 10px rgba(0,0,0,0.05) !important; } .card-back img { filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); opacity: 0.85; width: 70% !important;} .card-diamond { width: 50px !important; height: 50px !important; background-color: #ffffff !important; transform: rotate(45deg) !important; display: flex !important; justify-content: center !important; align-items: center !important; margin: 8px auto 6px auto !important; box-shadow: inset 0 2px 5px rgba(0,0,0,0.2) !important; border-radius: 8px !important; } .card-symbol { transform: rotate(-45deg) !important; font-size: 24px !important; font-weight: bold !important; } .card-title-text { font-size: 8px !important; font-weight: 900 !important; text-align: center !important; overflow-wrap: anywhere !important; line-height: 1.08 !important; text-transform: uppercase !important; min-height: 20px !important; display: flex !important; align-items: center !important; justify-content: center !important; } .text-light { color: #ffffff !important; text-shadow: 0 1px 3px rgba(0,0,0,0.95), 0 0 2px rgba(0,0,0,0.8) !important; } .text-dark { color: #111115 !important; text-shadow: 0 1px 0 rgba(255,255,255,0.35) !important; } .card-stats { margin-top: auto !important; display: flex !important; justify-content: space-between !important; gap: 2px !important; font-size: 8px !important; font-weight: 900 !important; border-top: 1.5px dashed rgba(255,255,255,0.45) !important; padding-top: 3px !important; line-height: 1 !important; white-space: nowrap !important; } .card-stats span { display: inline-flex !important; align-items: center !important; justify-content: center !important; flex: 1 1 0 !important; min-width: 0 !important; padding: 2px 1px !important; border-radius: 3px !important; background: rgba(0,0,0,0.62) !important; box-shadow: 0 1px 2px rgba(0,0,0,0.45) !important; white-space: nowrap !important; } .stat-good { color: #d9ff5a !important; text-shadow: 0 1px 2px rgba(0,0,0,1) !important; } .stat-bad { color: #ffc7d1 !important; text-shadow: 0 1px 2px rgba(0,0,0,1) !important; } .faded { opacity: 0.3 !important; } .log-box::-webkit-scrollbar { width: 4px; } .log-box::-webkit-scrollbar-track { background: transparent; } .log-box::-webkit-scrollbar-thumb { background: #44345d; border-radius: 4px; } .log-box { width: 100% !important; flex-grow: 1 !important; overflow-y: auto !important; color: #2ecc71 !important; font-size: 13px !important; line-height: 1.4 !important; padding-right: 5px !important; max-height: calc(100vh - 80px); scrollbar-width: thin; scrollbar-color: #44345d transparent; } @keyframes pulse-glow { 0% { border-color: #00f3ff; box-shadow: 0 0 8px rgba(0, 243, 255, 0.2); } 50% { border-color: #00a8ff; box-shadow: 0 0 20px rgba(0, 243, 255, 0.6); } 100% { border-color: #00f3ff; box-shadow: 0 0 8px rgba(0, 243, 255, 0.2); } } .panel-hand { background: radial-gradient(circle, #1a365d 0%, #071426 100%) !important; border: 1.5px solid rgba(255, 255, 255, 0.08) !important; border-radius: 10px !important; padding: 10px !important; opacity: 0.45 !important; transition: opacity 0.3s !important; color: #ffffff !important;} .panel-active { opacity: 1 !important; border: 1.5px solid #00f3ff !important; animation: pulse-glow 2.5s infinite ease-in-out !important; } .hand-title { margin: 0 0 8px 0 !important; color: #00f3ff !important; font-size: 13px !important; display: flex !important; justify-content: space-between !important; align-items: center !important; font-weight: bold !important;} .accuse-btn { background-color: #ff0055 !important; color: white !important; border: none !important; padding: 4px 10px !important; border-radius: 4px !important; font-size: 11px !important; cursor: pointer !important; font-weight: bold; box-shadow: 0 0 8px #ff0055 !important; margin-top: 5px;} #bg_canvas { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: -1; pointer-events: none; } """ html_template = """ ${(function() { const maxP = (value && value.max_players) ? value.max_players : 4; const initialI18n = (value && value.i18n) ? value.i18n : {}; const muted = window.dodAudioMuted === undefined ? true : window.dodAudioMuted === true; const audioIcon = muted ? "🔇" : "🔊"; const audioTitle = muted ? (initialI18n.unmute_audio || "Unmute audio") : (initialI18n.mute_audio || "Mute audio"); const audioClass = muted ? "audio-toggle-btn is-muted" : "audio-toggle-btn"; const audioToggleHtml = "
"; if (!value || (!value.game_started && !value.restart_countdown)) { const waitingStr = (value && value.i18n) ? value.i18n.waiting : "Waiting for {num} players to start..."; const txt = waitingStr.replace("{num}", maxP); const warmingUp = value && value.is_warming_up; const startCountdown = (value && value.lobby_start_countdown) ? value.lobby_start_countdown : 0; const countdownStr = (value && value.i18n && value.i18n.start_countdown) ? value.i18n.start_countdown : "Starting with current players in {sec}s..."; const warmupStr = (value && value.i18n && value.i18n.warmup_status) ? value.i18n.warmup_status : "DOD UNO: Cooking cloud audio assets... Please wait about 30-50 seconds!"; const warmupHtml = warmingUp ? "
" + warmupStr + "
" : ""; const countdownHtml = (!warmingUp && startCountdown > 0) ? "
" + countdownStr.replace("{sec}", startCountdown) + "
" : ""; return "
" + audioToggleHtml + txt + countdownHtml + warmupHtml + "
"; } const t = value.i18n; const stackLabels = { green: t.stack_green || "FRONTEND", blue: t.stack_blue || "BACKEND", red: t.stack_red || "DEVOPS", yellow: t.stack_yellow || "A.I." }; const res = value.resolution; const panic = value.panic; const activeCard = value.active_card; const activePlayer = value.active_player; const isPickingColor = value.is_picking_color; const hasDrawn = value.has_drawn_this_turn; const waitingShout = value.waiting_for_shout; const hands = value.hands; const shoutedDeploy = value.has_shouted_deploy; const log = value.game_log; const players = value.players; const playerPictures = value.player_pictures || {}; const queue = value.queue || []; const crisis = value.current_crisis; const drawPileIcons = [ "/gradio_api/file=assets/icon_huggingface.png", "/gradio_api/file=assets/icon_modal.png", "/gradio_api/file=assets/icon_nvidia.png", "/gradio_api/file=assets/icon_openbmb.png", "/gradio_api/file=assets/icon_openai.png", "/gradio_api/file=assets/icon_gradio.png" ]; if (!window.__dodDrawPileIcon) { window.__dodDrawPileIcon = drawPileIcons[Math.floor(Math.random() * drawPileIcons.length)]; } const escapeAttr = (raw) => String(raw || "").replaceAll("&", "&").replaceAll("'", "'").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">"); const myId = value.viewer_id !== undefined ? value.viewer_id : ""; const pIdx = players.indexOf(myId); const isSpectator = (pIdx === -1); const inQueue = (queue.indexOf(myId) !== -1); const queuePos = inQueue ? queue.indexOf(myId) + 1 : 0; const amIActive = (pIdx !== -1 && pIdx === activePlayer); const activeHand = isSpectator ? [] : hands[pIdx]; const showPicker = isPickingColor && amIActive; const pickerDisplay = showPicker ? 'flex !important' : 'none !important'; const isTurnStartShout = value.is_turn_start_shout || false; const passBtnDisplay = (amIActive && hasDrawn && !waitingShout) ? 'block' : 'none'; const canShout = (activeHand.length === 1); const countdown = value.shout_countdown || 0; const shoutBtnDisplay = (amIActive && canShout && !shoutedDeploy[pIdx] && countdown > 0) ? 'block' : 'none'; const layoutClass = isSpectator ? "game-layout spectator-mode" : "game-layout"; let html = "
" + audioToggleHtml; html += "
"; if (!value.game_started && value.restart_countdown > 0) { html += "
" + t.restarting.replace("{sec}", value.restart_countdown) + "
"; } let opponentsHtml = "
"; players.forEach((pName, i) => { if (i === pIdx) return; const isActive = (activePlayer === i); const pOneCard = (hands[i].length === 1); const pShouted = shoutedDeploy[i]; let riskHtml = ""; if (pOneCard && !pShouted && !waitingShout) { riskHtml = "
" + t.risk + "
"; } const showAccuse = (pOneCard && !pShouted && !isSpectator && !waitingShout && !value.pending_wild_shout); let accuseHtml = showAccuse ? "" : ""; const isBot = (pName.toLowerCase() === "nemotron"); const avatarUrl = playerPictures[pName] || (isBot ? "/gradio_api/file=assets/nemotron.jpg" : ""); const avatarImg = avatarUrl ? "" + escapeAttr(pName) + "" : ""; opponentsHtml += "
" + "
" + pName + "
" + "
" + avatarImg + " x" + hands[i].length + "
" + riskHtml + accuseHtml + "
"; }); opponentsHtml += "
"; html += opponentsHtml; const activeBadgeHtml = (activeCard && activeCard.badge) ? "
" + activeCard.badge + "
" : ""; const activeStack = activeCard ? activeCard.stack : "wild"; const activeSymbol = activeCard ? activeCard.categorySymbol : "🚀"; const activeName = activeCard ? activeCard.name : (t.final_deploy || "Final Deploy"); const activeRes = activeCard ? activeCard.res : 0; const activePanic = activeCard ? activeCard.panic : 0; html += "
" + "
" + t.status + " " + crisis.title + "
" + "
" + crisis.desc + "
" + "
" + "
" + t.res + "" + res + "%
" + "
" + "
" + "
" + "
" + t.panic + "" + panic + "%
" + "
" + "
" + "
"; html += "
" + "
" + "
" + t.pick + "
" + "
" + " " + " " + " " + " " + "
" + "
" + "
" + "
" + t.draw + "
" + "
" + "
" + " " + " " + t.draw_btn + "" + "
" + "
" + "
" + "
" + " " + " " + "
" + "
" + "
" + t.table + "
"; html += "
" + activeBadgeHtml + "
" + activeSymbol + "
" + "
" + activeName + "
" + "
" + " Res: " + (activeRes >= 0 ? '+' : '') + activeRes + "%" + " Pan: " + (activePanic >= 0 ? '+' : '') + activePanic + "%" + "
" + "
" + "
" + "
"; if (!isSpectator) { const isActive = (activePlayer === pIdx); const pOneCard = (activeHand.length === 1); const pShouted = shoutedDeploy[pIdx]; let badgeText = ""; if (pOneCard) { if (pShouted) badgeText = t.deploy_saved; else if (!waitingShout) badgeText = t.risk; } const myAvatarUrl = "/gradio_api/file=assets/icon_gradio.png"; const myAvatarImg = ""; html += "
" + "
" + "

" + " " + myId + " " + t.you + " (" + myAvatarImg + "x" + activeHand.length + ") " + badgeText + "" + " " + "

" + "
"; activeHand.forEach((card, cIdx) => { const resColor = card.res >= 0 ? 'stat-good' : 'stat-bad'; const panicColor = card.panic <= 0 ? 'stat-good' : 'stat-bad'; const textClass = card.stack === 'yellow' ? 'text-dark' : 'text-light'; const cardBadgeHtml = card.badge ? "
" + card.badge + "
" : ""; html += "
" + cardBadgeHtml + "
" + card.categorySymbol + "
" + "
" + card.name + "
" + "
" + " Res: " + (card.res >= 0 ? '+' : '') + card.res + "%" + " Pan: " + (card.panic >= 0 ? '+' : '') + card.panic + "%" + "
" + "
"; }); html += "
"; } else if (inQueue) { const qMsg = t.queue_msg.replace("{pos}", queuePos); html += "
" + qMsg + "
"; } html += "
"; const timerText = value.game_started ? " (" + (t.turn || "Turn") + ": " + value.turn_left + "s)" : ""; html += "
" + "

" + t.log + timerText + "

" + "
" + log + "
" + "
"; return html; })()} """ js_on_load = """ const getMyId = () => { if (props.value && props.value.viewer_id !== undefined) return props.value.viewer_id; return ""; }; const selectLobbyTab = () => { setTimeout(() => { const tabButtons = document.querySelectorAll('#main_tabs > .tab-nav > button'); if (tabButtons && tabButtons[0]) tabButtons[0].click(); }, 0); }; const returnToLobbyUi = () => { if (window._returningToLobby) return; window._returningToLobby = true; localStorage.removeItem('uno_name'); localStorage.removeItem('uno_lang'); selectLobbyTab(); trigger('force_leave_ui'); setTimeout(selectLobbyTab, 150); setTimeout(() => { window._returningToLobby = false; }, 1200); }; const scheduleEndGameLobbyReturn = () => { if (window.endGameReturnTimer) { clearTimeout(window.endGameReturnTimer); } const restartSeconds = props.value && props.value.restart_countdown ? props.value.restart_countdown : 0; window.endGameReturnTimer = setTimeout(() => { if (props.value && !props.value.game_started) { returnToLobbyUi(); } window.endGameReturnTimer = null; }, Math.max(0, restartSeconds * 1000) + 1200); }; if (window.dodAudioMuted === undefined) { window.dodAudioMuted = true; localStorage.setItem("dod_audio_muted", "true"); } const syncAudioToggle = () => { const btn = element.querySelector('#btn-audio-toggle'); if (!btn) return; const isMuted = window.dodAudioMuted === true; const i18n = (props.value && props.value.i18n) ? props.value.i18n : {}; btn.textContent = isMuted ? "🔇" : "🔊"; btn.title = isMuted ? (i18n.unmute_audio || "Unmute audio") : (i18n.mute_audio || "Mute audio"); btn.setAttribute("aria-label", btn.title); btn.classList.toggle("is-muted", isMuted); }; const drawPileIcons = [ "/gradio_api/file=assets/icon_huggingface.png", "/gradio_api/file=assets/icon_modal.png", "/gradio_api/file=assets/icon_nvidia.png", "/gradio_api/file=assets/icon_openbmb.png", "/gradio_api/file=assets/icon_openai.png", "/gradio_api/file=assets/icon_gradio.png" ]; const rotateDrawPileIcon = () => { const currentIcon = window.__dodDrawPileIcon || ""; const candidates = drawPileIcons.filter((icon) => icon !== currentIcon); const nextIcon = candidates[Math.floor(Math.random() * candidates.length)] || drawPileIcons[0]; window.__dodDrawPileIcon = nextIcon; const iconEl = element.querySelector('#draw-pile-icon'); if (iconEl) { iconEl.src = nextIcon; } }; const handleServerResponse = (response) => { if (response) { if (response.toast && response.toast !== "") trigger('show_toast', {"msg": response.toast}); if (response.state) { response.state.viewer_id = getMyId(); props.value = response.state; } } }; element.addEventListener('click', async (e) => { if (window.gameAudio) { window.gameAudio.init(); } const myId = getMyId(); if (e.target.id === 'btn-audio-toggle') { window.dodAudioMuted = !(window.dodAudioMuted === true); localStorage.setItem("dod_audio_muted", window.dodAudioMuted ? "true" : "false"); if (window.dodAudioMuted) { stopDirectorAudioQueue(false); } syncAudioToggle(); if (window.dodLobbyMusic) { window.dodLobbyMusic.sync(props.value || null); } return; } if (!myId || myId === "") return; if (e.target.id === 'btn-leave') { const res = await server.leave_game({ caller: myId }); returnToLobbyUi(); handleServerResponse(res); return; } if (props.value && props.value.game_started && (props.value.turn_handoff_left || 0) > 0) { return; } const drawPileBtn = e.target.closest('#draw-pile'); if (drawPileBtn) { const players = (props.value && props.value.players) ? props.value.players : []; const myIndex = players.indexOf(myId); const canAttemptDraw = ( props.value && props.value.game_started && myIndex === props.value.active_player && !props.value.waiting_for_shout && !props.value.is_picking_color ); const res = await server.draw_card({ caller: myId }); if (canAttemptDraw && res && !res.toast) { if (window.gameAudio) window.gameAudio.play('draw'); rotateDrawPileIcon(); } handleServerResponse(res); return; } const cardEl = e.target.closest('.card'); if (cardEl && cardEl.dataset.player !== undefined) { const pIdx = parseInt(cardEl.dataset.player); const res = await server.play_card({ player: pIdx, card: parseInt(cardEl.dataset.card), caller: myId }); handleServerResponse(res); return; } const accuseBtn = e.target.closest('.accuse-btn'); if (accuseBtn) { if (window.gameAudio) window.gameAudio.play('warning'); const res = await server.accuse_player({ target: parseInt(accuseBtn.dataset.target), caller: myId }); handleServerResponse(res); return; } const colorBtn = e.target.closest('.color-btn'); if (colorBtn) { if (window.gameAudio) window.gameAudio.play('play'); const res = await server.select_wild_color({ color: colorBtn.dataset.color, caller: myId }); handleServerResponse(res); return; } if (e.target.id === 'btn-pass') { if (window.gameAudio) window.gameAudio.play('play'); const res = await server.pass_turn_manual({ caller: myId }); handleServerResponse(res); return; } if (e.target.id === 'btn-shout') { if (window.gameAudio) window.gameAudio.play('shout'); const res = await server.shout_deploy({ caller: myId }); handleServerResponse(res); return; } }); let shoutTimer = null; let countdownInterval = null; let isWaitingShoutLocal = false; let timeLeft = (props.value && props.value.shout_countdown) ? props.value.shout_countdown : 3; function stopDirectorAudioQueue(resetLastId = true) { if (window._activeDirectorAudio) { window._activeDirectorAudio.pause(); window._activeDirectorAudio.currentTime = 0; window._activeDirectorAudio = null; } window._audioQueue = []; window._isAudioPlaying = false; if (resetLastId) { window._lastDirectorAudioId = null; } } watch('value', () => { window.dodAudioMuted = localStorage.getItem("dod_audio_muted") !== "false"; syncAudioToggle(); if (window.dodLobbyMusic) { window.dodLobbyMusic.sync(props.value || null); } const myId = getMyId(); if (!props.value) return; if (myId && myId !== "") { const players = props.value.players || []; const queue = props.value.queue || []; const stillInGame = (players.indexOf(myId) !== -1 || queue.indexOf(myId) !== -1); if (!stillInGame) { if (!window.kickTimer) { window.kickTimer = setTimeout(() => { returnToLobbyUi(); window.kickTimer = null; }, 1500); } return; } else { if (window.kickTimer) { clearTimeout(window.kickTimer); window.kickTimer = null; } } } if (!props.value.players) return; const pIdx = props.value.players.indexOf(myId); const amIActive = (pIdx !== -1 && pIdx === props.value.active_player); const isWaiting = props.value.waiting_for_shout; const wasStarted = window._lastGameStarted; const isStarted = props.value.game_started; const localName = localStorage.getItem('uno_name') || ""; if (isStarted && localName !== "") { const players = props.value.players || []; if (players.indexOf(localName) !== -1) { const tabButtons = document.querySelectorAll('#main_tabs > .tab-nav > button'); if (tabButtons && tabButtons[1] && !tabButtons[1].classList.contains('selected')) { tabButtons[1].click(); // Redirects to 'Your Game' tab instantly! if (window.dodLobbyMusic) { window.dodLobbyMusic.sync(props.value || null); } } } } if (isStarted && wasStarted !== true) { if (window.endGameReturnTimer) { clearTimeout(window.endGameReturnTimer); window.endGameReturnTimer = null; } window._lastEndToastKey = null; window._returningToLobby = false; if (window.dodClearToast) window.dodClearToast(); if (wasStarted === false && window.gameAudio) window.gameAudio.play('shout'); } else if (wasStarted === true && !isStarted) { stopDirectorAudioQueue(); const endReason = props.value.game_end_reason || ""; const endToastKey = [ endReason || "ended", props.value.resolution || 0, props.value.panic || 0 ].join(":"); if (window._lastEndToastKey !== endToastKey) { window._lastEndToastKey = endToastKey; if (endReason === "victory") { if (window.gameAudio) window.gameAudio.play('victory'); trigger('show_toast', {"msg": props.value.i18n.toast_victory}); } else if (endReason === "abandon") { if (window.gameAudio) window.gameAudio.play('game_over'); trigger('show_toast', {"msg": props.value.i18n.toast_game_over_abandon}); } else if (endReason === "game_over" || endReason === "timeout") { if (window.gameAudio) window.gameAudio.play('game_over'); trigger('show_toast', {"msg": props.value.i18n.toast_game_over}); } else if (props.value.panic >= 100) { if (window.gameAudio) window.gameAudio.play('game_over'); trigger('show_toast', {"msg": props.value.i18n.toast_game_over}); } else if (props.value.resolution >= 100) { if (window.gameAudio) window.gameAudio.play('victory'); trigger('show_toast', {"msg": props.value.i18n.toast_victory}); } } scheduleEndGameLobbyReturn(); } window._lastGameStarted = isStarted; if (!isStarted) { stopDirectorAudioQueue(); } if (!window._playAudioQueue) { window._playAudioQueue = function() { if (window.dodAudioMuted) { stopDirectorAudioQueue(false); return; } if (window._isAudioPlaying) { return; // Wait for the active audio to finish speaking } if (!window._audioQueue || window._audioQueue.length === 0) { return; // No pending quotes in playlist } const nextItem = window._audioQueue.shift(); try { const dirAudio = new Audio("data:audio/wav;base64," + nextItem.b64); dirAudio.volume = 0.85; window._activeDirectorAudio = dirAudio; window._isAudioPlaying = true; // Trigger next audio recursively when current audio ends dirAudio.onended = function() { window._isAudioPlaying = false; window._activeDirectorAudio = null; window._playAudioQueue(); }; dirAudio.onerror = function() { window._isAudioPlaying = false; window._activeDirectorAudio = null; window._playAudioQueue(); }; dirAudio.play().catch(e => { console.warn("Audio playback blocked by browser:", e); window._isAudioPlaying = false; window._activeDirectorAudio = null; window._playAudioQueue(); }); } catch (e) { console.error("Failed to initialize HTML5 Audio element:", e); window._isAudioPlaying = false; window._playAudioQueue(); } }; } if (isStarted && props.value.active_card) { const currentCardId = props.value.active_card.id; if (window._lastCardId !== undefined && window._lastCardId !== currentCardId) { const card = props.value.active_card; const isSpecialAttack = card.drawTwo || card.drawFour || card.skip || card.reverse || card.category === 'NUKE' || card.category === 'ATTACK' || card.category === 'SKIP' || card.category === 'REVERSE'; if (window.gameAudio) { if (isSpecialAttack) { window.gameAudio.play('attack'); } else { window.gameAudio.play('play'); } } } window._lastCardId = currentCardId; } if (props.value.panic >= 80 && isStarted) { const nowTime = Date.now(); if (!window._lastWarnTime || nowTime - window._lastWarnTime > 8000) { if (window.gameAudio) window.gameAudio.play('warning'); window._lastWarnTime = nowTime; } } if (isStarted && props.value.director_audio && props.value.director_audio.b64 !== "") { const audioId = props.value.director_audio.id; if (window._lastDirectorAudioId !== audioId) { window._lastDirectorAudioId = audioId; if (window.dodAudioMuted) { stopDirectorAudioQueue(false); return; } try { if (!window._audioQueue) { window._audioQueue = []; } // Push the newly received audio to the queue window._audioQueue.push({ id: audioId, b64: props.value.director_audio.b64 }); // Trigger the queue player execution window._playAudioQueue(); } catch(e) { console.error("Audio Queue push error", e); } } } if (amIActive && isWaiting && !isWaitingShoutLocal) { isWaitingShoutLocal = true; timeLeft = props.value.shout_countdown || 3; const btnShoutInit = element.querySelector('#btn-shout'); let shoutTxt = btnShoutInit ? btnShoutInit.dataset.txt : props.value.i18n.shout; if (btnShoutInit) btnShoutInit.innerText = shoutTxt + " (" + timeLeft + "s)"; if (window.gameAudio) window.gameAudio.play('warning'); countdownInterval = setInterval(() => { timeLeft--; if (window.gameAudio) window.gameAudio.play('tick'); const currentBtn = element.querySelector('#btn-shout'); if (currentBtn) currentBtn.innerText = shoutTxt + " (" + timeLeft + "s)"; }, 1000); shoutTimer = setTimeout(async () => { clearInterval(countdownInterval); isWaitingShoutLocal = false; const res = await server.pass_turn_manual({ caller: myId }); handleServerResponse(res); }, (props.value.shout_countdown || 3) * 1000); } else if (!isWaiting || !amIActive) { if (isWaitingShoutLocal) { clearTimeout(shoutTimer); clearInterval(countdownInterval); isWaitingShoutLocal = false; } } if (isWaitingShoutLocal) { const currentBtn = element.querySelector('#btn-shout'); let shoutTxt = currentBtn ? currentBtn.dataset.txt : props.value.i18n.shout; if (currentBtn) currentBtn.innerText = shoutTxt + " (" + timeLeft + "s)"; } setTimeout(() => { const logBox = element.querySelector('#auto-scroll-log'); if (logBox) logBox.scrollTop = logBox.scrollHeight; }, 150); }); """ super().__init__( value=value, html_template=html_template, css_template=css_template, js_on_load=js_on_load, server_functions=server_functions, **kwargs, )