From 998774bf1c981c1d2dd5b593a28995a63d7cd2b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 25 May 2026 14:22:47 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=B3=D0=BE=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B0=D1=82=D1=87=D0=B0=20=D0=B2=D0=BE=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=D1=85=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D1=85,=20=D1=82=D0=B5=D1=81=D1=82=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 78 +++++++++++++++++++++++++++++++ static/script.js | 116 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/app.py b/app.py index 5eb0496..fe7a85c 100644 --- a/app.py +++ b/app.py @@ -440,6 +440,49 @@ def logout(request: Request): return response + +class ClockStatePayload(BaseModel): + currentPeriod: str | None = None + matchClockSeconds: int = 0 + timerRunning: bool = False + pausedAccumulatedSeconds: int | None = None + sourceClientId: str | None = None + + +# Простое общее состояние часов для всех браузеров. +# localStorage остаётся резервом на стороне клиента. +MATCH_CLOCK_STATES: Dict[int, dict] = {} + + +def build_clock_state_payload(match_id: int) -> dict: + state = MATCH_CLOCK_STATES.get(match_id) + if not state: + return { + "exists": False, + "currentPeriod": None, + "matchClockSeconds": 0, + "timerRunning": False, + "serverSavedAt": None, + "sourceClientId": None, + } + + seconds = int(state.get("matchClockSeconds") or 0) + timer_running = bool(state.get("timerRunning")) + saved_at = float(state.get("serverSavedAt") or time.time()) + + if timer_running: + seconds += max(0, int(time.time() - saved_at)) + + return { + "exists": True, + "currentPeriod": state.get("currentPeriod"), + "matchClockSeconds": max(0, seconds), + "timerRunning": timer_running, + "serverSavedAt": saved_at, + "sourceClientId": state.get("sourceClientId"), + } + + class MatchEventPayload(BaseModel): side: str type: str @@ -1082,6 +1125,41 @@ def save_formation(session_token: str, payload: FormationSavePayload): return {"success": True} +@app.get("/admin/session/{session_token}/clock-state") +def api_get_clock_state(session_token: str): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + match_id = session_row[1] + return build_clock_state_payload(match_id) + + +@app.post("/admin/session/{session_token}/clock-state") +def api_save_clock_state(session_token: str, payload: ClockStatePayload): + session_row = get_match_session_by_token(session_token) + if not session_row: + return JSONResponse({"error": "session_not_found"}, status_code=404) + + match_id = session_row[1] + seconds = max(0, int(payload.matchClockSeconds or 0)) + + MATCH_CLOCK_STATES[match_id] = { + "currentPeriod": payload.currentPeriod, + "matchClockSeconds": seconds, + "timerRunning": bool(payload.timerRunning), + "pausedAccumulatedSeconds": ( + int(payload.pausedAccumulatedSeconds) + if payload.pausedAccumulatedSeconds is not None + else seconds + ), + "serverSavedAt": time.time(), + "sourceClientId": payload.sourceClientId, + } + + return build_clock_state_payload(match_id) + + @app.get("/admin/session/{session_token}/events") def api_get_events(session_token: str): session_row = get_match_session_by_token(session_token) diff --git a/static/script.js b/static/script.js index 6072fe8..2c1146c 100644 --- a/static/script.js +++ b/static/script.js @@ -64,6 +64,17 @@ let originalLineupState = null; const SESSION_TOKEN = MATCH_DATA.sessionToken ?? null; const MATCH_ID = MATCH_DATA.matchId ?? null; const STORAGE_KEY = `match_admin_state_match_${MATCH_ID}`; +const CLOCK_CLIENT_ID_KEY = `match_admin_clock_client_${MATCH_ID}`; +let CLOCK_CLIENT_ID = localStorage.getItem(CLOCK_CLIENT_ID_KEY); +if (!CLOCK_CLIENT_ID) { + CLOCK_CLIENT_ID = `${Date.now()}_${Math.random().toString(16).slice(2)}`; + localStorage.setItem(CLOCK_CLIENT_ID_KEY, CLOCK_CLIENT_ID); +} +let lastClockServerSyncAt = 0; +let clockServerSyncInFlight = false; +let applyingRemoteClockState = false; +let clockEditorIsOpen = false; + const formationState = { home: Array.isArray(MATCH_DATA.homeFormations) ? MATCH_DATA.homeFormations : [], @@ -315,11 +326,106 @@ function saveMatchState() { }; localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + syncClockStateToServer(false); } catch (e) { console.warn("Не удалось сохранить состояние матча", e); } } +async function syncClockStateToServer(force = false) { + if (!SESSION_TOKEN || applyingRemoteClockState) return; + + const now = Date.now(); + if (!force && now - lastClockServerSyncAt < 1500) return; + if (clockServerSyncInFlight) return; + + lastClockServerSyncAt = now; + clockServerSyncInFlight = true; + + try { + const liveSeconds = getLiveClockSeconds(); + await fetch(`/admin/session/${SESSION_TOKEN}/clock-state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPeriod, + matchClockSeconds: liveSeconds, + timerRunning: !!timerId, + pausedAccumulatedSeconds: liveSeconds, + sourceClientId: CLOCK_CLIENT_ID + }) + }); + } catch (e) { + console.warn("Не удалось синхронизировать время с сервером", e); + } finally { + clockServerSyncInFlight = false; + } +} + +async function loadClockStateFromServer() { + if (!SESSION_TOKEN || clockEditorIsOpen) return false; + + try { + const res = await fetch(`/admin/session/${SESSION_TOKEN}/clock-state`, { + cache: "no-store" + }); + if (!res.ok) return false; + + const state = await res.json(); + if (!state || !state.exists) return false; + + // Если это только что отправил этот же браузер — не дёргаем локальный таймер лишний раз. + if (state.sourceClientId && state.sourceClientId === CLOCK_CLIENT_ID) { + return true; + } + + applyingRemoteClockState = true; + + currentPeriod = state.currentPeriod ?? currentPeriod; + const seconds = Math.max(0, Number(state.matchClockSeconds) || 0); + const shouldRun = !!state.timerRunning && (currentPeriod === "1H" || currentPeriod === "2H"); + + if (timerId) { + clearInterval(timerId); + timerId = null; + } + + pausedAccumulatedSeconds = seconds; + matchClockSeconds = seconds; + periodStartedAt = shouldRun ? Date.now() : null; + + if (shouldRun) { + timerId = setInterval(() => { + updateTimerUI(); + saveMatchState(); + }, 1000); + } + + updateTimerUI(); + updatePeriodBadge( + currentPeriod === "1H" ? (extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм") : + currentPeriod === "HT" ? "Перерыв" : + currentPeriod === "2H" ? (extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм") : + currentPeriod === "FT" ? "Матч завершён" : + "Матч не начат" + ); + + saveMatchState(); + updatePeriodButtons(); + return true; + } catch (e) { + console.warn("Не удалось получить время с сервера", e); + return false; + } finally { + applyingRemoteClockState = false; + } +} + +function initClockStateSync() { + loadClockStateFromServer(); + setInterval(loadClockStateFromServer, 2000); +} + function restoreMatchState() { const state = getPersistedState(); if (!state) return false; @@ -407,12 +513,15 @@ async function syncEditedClockToVmix(totalSeconds, shouldRun) { } function closeClockEditor() { + clockEditorIsOpen = false; const modal = document.getElementById("clockEditorModal"); if (modal) modal.classList.remove("show"); } function openClockEditor() { + clockEditorIsOpen = true; if (!(currentPeriod === "1H" || currentPeriod === "2H")) { + clockEditorIsOpen = false; alert("Редактировать время можно после запуска 1-го или 2-го тайма"); return; } @@ -472,6 +581,7 @@ async function applyClockEditor() { updateTimerUI(); saveMatchState(); + await syncClockStateToServer(true); closeClockEditor(); await syncEditedClockToVmix(newSeconds, wasRunning); } @@ -710,6 +820,7 @@ function setMatchPeriod(period) { addPeriodMarker("period_start_1h", "Начало 1-го тайма", 0); triggerVmixTimerAction("first_half_start"); saveMatchState(); + syncClockStateToServer(true); clearExtraTimeInput(); updatePeriodButtons(); return; @@ -725,6 +836,7 @@ function setMatchPeriod(period) { addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds); triggerVmixTimerAction("halftime"); saveMatchState(); + syncClockStateToServer(true); clearExtraTimeInput(); updatePeriodButtons(); return; @@ -747,6 +859,7 @@ function setMatchPeriod(period) { triggerVmixTimerAction("second_half_start"); startTimer(); saveMatchState(); + syncClockStateToServer(true); updatePeriodButtons(); return; } @@ -757,6 +870,7 @@ function setMatchPeriod(period) { addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds()); triggerVmixTimerAction("full_time"); saveMatchState(); + syncClockStateToServer(true); clearExtraTimeInput(); updatePeriodButtons(); } @@ -1022,6 +1136,7 @@ function clearMatchEvents() { renderEvents(); saveMatchState(); + syncClockStateToServer(true); clearEventsOnServer(); updateCoachCardButtons(); updatePeriodButtons(); @@ -2416,6 +2531,7 @@ if (!restored) { } loadEventsFromServer(); +initClockStateSync(); window.addEventListener("beforeunload", saveMatchState); window.addEventListener("pagehide", saveMatchState); document.addEventListener("visibilitychange", () => {