Spaces:
Running
Running
| 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 = """ | |
| <div id="toast-container" class="toast-hidden"> | |
| <span id="toast-msg">${value}</span> | |
| </div> | |
| """ | |
| 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 = "<div class='board-toolbar'><button id='btn-audio-toggle' class='" + audioClass + "' title='" + audioTitle + "' aria-label='" + audioTitle + "'>" + audioIcon + "</button></div>"; | |
| 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 | |
| ? "<div style='display: inline-flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; font-size: 14px; color: #00f3ff !important; text-align: center;'><div class='game-spinner'></div>" + warmupStr + "</div>" | |
| : ""; | |
| const countdownHtml = (!warmingUp && startCountdown > 0) | |
| ? "<div style='margin-top: 10px; font-size: 14px; color: #00f3ff !important; text-align: center;'>" + countdownStr.replace("{sec}", startCountdown) + "</div>" | |
| : ""; | |
| return "<div class='game-layout' style='justify-content: center; align-items: center; font-size: 20px; color: white !important; flex-direction: column; position: relative;'>" + audioToggleHtml + txt + countdownHtml + warmupHtml + "</div>"; | |
| } | |
| 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 = "<div class='" + layoutClass + "' style='position: relative;'>" + audioToggleHtml; | |
| html += "<div class='left-col'><div class='board-container'>"; | |
| if (!value.game_started && value.restart_countdown > 0) { | |
| html += "<div class='restart-banner'>" + t.restarting.replace("{sec}", value.restart_countdown) + "</div>"; | |
| } | |
| let opponentsHtml = "<div class='opponents-row'>"; | |
| 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 = "<div class='opp-risk'>" + t.risk + "</div>"; | |
| } | |
| const showAccuse = (pOneCard && !pShouted && !isSpectator && !waitingShout && !value.pending_wild_shout); | |
| let accuseHtml = showAccuse ? "<button class='accuse-btn' data-target='" + i + "'>" + t.accuse + "</button>" : ""; | |
| const isBot = (pName.toLowerCase() === "nemotron"); | |
| const avatarUrl = playerPictures[pName] || (isBot ? "/gradio_api/file=assets/nemotron.jpg" : ""); | |
| const avatarImg = avatarUrl | |
| ? "<span class='opp-avatar'><img class='opp-avatar-img' src='" + escapeAttr(avatarUrl) + "' alt='" + escapeAttr(pName) + "'></span>" | |
| : "<span class='opp-avatar'><svg class='opp-avatar-default' viewBox='0 0 24 24'><path d='M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z'/></svg></span>"; | |
| opponentsHtml += "<div class='opponent-badge " + (isActive ? "active" : "") + "'>" + | |
| "<div class='opp-name'>" + pName + "</div>" + | |
| "<div class='opp-cards'>" + avatarImg + " x" + hands[i].length + "</div>" + | |
| riskHtml + accuseHtml + "</div>"; | |
| }); | |
| opponentsHtml += "</div>"; | |
| html += opponentsHtml; | |
| const activeBadgeHtml = (activeCard && activeCard.badge) ? "<div class='card-badge'>" + activeCard.badge + "</div>" : ""; | |
| 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 += "<div class='crisis-box'>" + | |
| " <div style='color: #ffffff !important; font-weight: bold;'>" + t.status + " <span class='alert-title'>" + crisis.title + "</span></div>" + | |
| " <div style='margin-top: 5px; font-size: 13px; color: #cbd5e0 !important; font-weight: bold; margin-bottom: 12px;'>" + crisis.desc + "</div>" + | |
| " <div class='status-container'>" + | |
| " <div class='bar-label'><span style='color: #ffffff !important;'>" + t.res + "</span><span style='color: #ffffff !important;'>" + res + "%</span></div>" + | |
| " <div class='progress-bar'><div class='progress-fill res-fill' style='width: " + res + "%'></div></div>" + | |
| " </div>" + | |
| " <div class='status-container' style='margin-top:10px;'>" + | |
| " <div class='bar-label'><span style='color: #ffffff !important;'>" + t.panic + "</span><span style='color: #ffffff !important;'>" + panic + "%</span></div>" + | |
| " <div class='progress-bar'><div class='progress-fill panic-fill' style='width: " + panic + "%'></div></div>" + | |
| " </div>" + | |
| "</div>"; | |
| html += "<div class='table-board'>" + | |
| " <div class='picker-box' style='display: " + pickerDisplay + ";'>" + | |
| " <div style='font-weight: bold; color: #00f3ff; font-size: 16px; letter-spacing: 1px;'>" + t.pick + "</div>" + | |
| " <div class='color-btns'>" + | |
| " <button class='color-btn' style='background-color: #2ecc71; color: #000;' data-color='green'>" + stackLabels.green + "</button>" + | |
| " <button class='color-btn' style='background-color: #3498db;' data-color='blue'>" + stackLabels.blue + "</button>" + | |
| " <button class='color-btn' style='background-color: #e74c3c;' data-color='red'>" + stackLabels.red + "</button>" + | |
| " <button class='color-btn' style='background-color: #f1c40f; color: #000;' data-color='yellow'>" + stackLabels.yellow + "</button>" + | |
| " </div>" + | |
| " </div>" + | |
| " <div style='display: flex; flex-direction: column; align-items: center; gap: 8px;'>" + | |
| " <div style='font-size: 12px; color: #cbd5e0; font-weight: bold;'>" + t.draw + "</div>" + | |
| " <div id='draw-pile' class='draw-pile-btn card-large'>" + | |
| " <div class='draw-pile-card-face'>" + | |
| " <img id='draw-pile-icon' class='draw-pile-logo' src='" + window.__dodDrawPileIcon + "' alt='" + (t.draw_pile_alt || "Draw pile provider") + "'>" + | |
| " <span class='draw-pile-label'>" + t.draw_btn + "</span>" + | |
| " </div>" + | |
| " </div>" + | |
| " </div>" + | |
| " <div style='display: flex; flex-direction: column; justify-content: center; gap: 10px; width: 120px;'>" + | |
| " <button id='btn-pass' class='action-btn pass-btn' style='display: " + passBtnDisplay + "'>" + t.pass + "</button>" + | |
| " <button id='btn-shout' class='action-btn shout-btn' style='display: " + shoutBtnDisplay + "' data-txt='" + t.shout + "'>" + t.shout + " (" + countdown + "s)</button>" + | |
| " </div>" + | |
| " <div style='display: flex; flex-direction: column; align-items: center; gap: 8px;'>" + | |
| " <div style='font-size: 12px; color: #cbd5e0; font-weight: bold;'>" + t.table + "</div>"; | |
| html += " <div class='card card-large card-bg-" + activeStack + "' style='margin: 0;'>" + activeBadgeHtml + | |
| " <div class='card-diamond'><span class='card-symbol' style='color: var(--" + activeStack + ")'>" + activeSymbol + "</span></div>" + | |
| " <div class='card-title-text " + (activeStack === 'yellow' ? 'text-dark' : 'text-light') + "'>" + activeName + "</div>" + | |
| " <div class='card-stats'>" + | |
| " <span class='" + (activeRes >= 0 ? 'stat-good' : 'stat-bad') + "'>Res: " + (activeRes >= 0 ? '+' : '') + activeRes + "%</span>" + | |
| " <span class='" + (activePanic <= 0 ? 'stat-good' : 'stat-bad') + "'>Pan: " + (activePanic >= 0 ? '+' : '') + activePanic + "%</span>" + | |
| " </div>" + | |
| " </div>" + | |
| " </div>" + | |
| "</div>"; | |
| 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 = "<img src='" + myAvatarUrl + "' style='width: 16px; height: 16px; vertical-align: middle; display: inline-block; margin-right: 4px; border-radius: 3px; object-fit: contain;'>"; | |
| html += "<div class='my-hand-panel'>" + | |
| " <div id='panel-p" + pIdx + "' class='panel-hand " + (isActive ? 'panel-active' : '') + "'>" + | |
| " <h3 class='hand-title'>" + | |
| " <span style='color:#ffffff !important;'>" + myId + " " + t.you + " <span style='color: #00f3ff; font-size: 12px; margin-left: 5px; font-weight: bold;'>(" + myAvatarImg + "x" + activeHand.length + ")</span><span style='color: #ff0055;'> " + badgeText + "</span></span>" + | |
| " <button id='btn-leave' class='btn-leave'>🚪 " + t.leave + "</button>" + | |
| " </h3>" + | |
| " <div class='cards-list-horizontal'>"; | |
| 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 ? "<div class='card-badge'>" + card.badge + "</div>" : ""; | |
| html += "<div class='card card-bg-" + card.stack + " " + (!isActive ? 'faded' : '') + "' data-player='" + pIdx + "' data-card='" + cIdx + "'>" + cardBadgeHtml + | |
| " <div class='card-diamond'><span class='card-symbol' style='color: var(--" + card.stack + ")'>" + card.categorySymbol + "</span></div>" + | |
| " <div class='card-title-text " + textClass + "'>" + card.name + "</div>" + | |
| " <div class='card-stats'>" + | |
| " <span class='" + resColor + "'>Res: " + (card.res >= 0 ? '+' : '') + card.res + "%</span>" + | |
| " <span class='" + panicColor + "'>Pan: " + (card.panic >= 0 ? '+' : '') + card.panic + "%</span>" + | |
| " </div>" + | |
| "</div>"; | |
| }); | |
| html += " </div></div></div>"; | |
| } else if (inQueue) { | |
| const qMsg = t.queue_msg.replace("{pos}", queuePos); | |
| html += "<div class='queue-banner'>" + qMsg + "</div>"; | |
| } | |
| html += "</div></div>"; | |
| const timerText = value.game_started ? " (" + (t.turn || "Turn") + ": " + value.turn_left + "s)" : ""; | |
| html += "<div class='right-col'>" + | |
| " <h3 style='color: #2ecc71 !important; margin-top: 0; font-size: 16px; border-bottom: 2px solid #44345d; padding-bottom: 10px;'>" + t.log + timerText + "</h3>" + | |
| " <div class='log-box' id='auto-scroll-log'>" + log + "</div>" + | |
| "</div></div>"; | |
| 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, | |
| ) | |