синхронизация текущего времени матча во всех браузерах, тест 1
This commit is contained in:
78
app.py
78
app.py
@@ -440,6 +440,49 @@ def logout(request: Request):
|
|||||||
return response
|
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):
|
class MatchEventPayload(BaseModel):
|
||||||
side: str
|
side: str
|
||||||
type: str
|
type: str
|
||||||
@@ -1082,6 +1125,41 @@ def save_formation(session_token: str, payload: FormationSavePayload):
|
|||||||
return {"success": True}
|
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")
|
@app.get("/admin/session/{session_token}/events")
|
||||||
def api_get_events(session_token: str):
|
def api_get_events(session_token: str):
|
||||||
session_row = get_match_session_by_token(session_token)
|
session_row = get_match_session_by_token(session_token)
|
||||||
|
|||||||
116
static/script.js
116
static/script.js
@@ -64,6 +64,17 @@ let originalLineupState = null;
|
|||||||
const SESSION_TOKEN = MATCH_DATA.sessionToken ?? null;
|
const SESSION_TOKEN = MATCH_DATA.sessionToken ?? null;
|
||||||
const MATCH_ID = MATCH_DATA.matchId ?? null;
|
const MATCH_ID = MATCH_DATA.matchId ?? null;
|
||||||
const STORAGE_KEY = `match_admin_state_match_${MATCH_ID}`;
|
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 = {
|
const formationState = {
|
||||||
home: Array.isArray(MATCH_DATA.homeFormations) ? MATCH_DATA.homeFormations : [],
|
home: Array.isArray(MATCH_DATA.homeFormations) ? MATCH_DATA.homeFormations : [],
|
||||||
@@ -315,11 +326,106 @@ function saveMatchState() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
syncClockStateToServer(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Не удалось сохранить состояние матча", 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() {
|
function restoreMatchState() {
|
||||||
const state = getPersistedState();
|
const state = getPersistedState();
|
||||||
if (!state) return false;
|
if (!state) return false;
|
||||||
@@ -407,12 +513,15 @@ async function syncEditedClockToVmix(totalSeconds, shouldRun) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeClockEditor() {
|
function closeClockEditor() {
|
||||||
|
clockEditorIsOpen = false;
|
||||||
const modal = document.getElementById("clockEditorModal");
|
const modal = document.getElementById("clockEditorModal");
|
||||||
if (modal) modal.classList.remove("show");
|
if (modal) modal.classList.remove("show");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openClockEditor() {
|
function openClockEditor() {
|
||||||
|
clockEditorIsOpen = true;
|
||||||
if (!(currentPeriod === "1H" || currentPeriod === "2H")) {
|
if (!(currentPeriod === "1H" || currentPeriod === "2H")) {
|
||||||
|
clockEditorIsOpen = false;
|
||||||
alert("Редактировать время можно после запуска 1-го или 2-го тайма");
|
alert("Редактировать время можно после запуска 1-го или 2-го тайма");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -472,6 +581,7 @@ async function applyClockEditor() {
|
|||||||
|
|
||||||
updateTimerUI();
|
updateTimerUI();
|
||||||
saveMatchState();
|
saveMatchState();
|
||||||
|
await syncClockStateToServer(true);
|
||||||
closeClockEditor();
|
closeClockEditor();
|
||||||
await syncEditedClockToVmix(newSeconds, wasRunning);
|
await syncEditedClockToVmix(newSeconds, wasRunning);
|
||||||
}
|
}
|
||||||
@@ -710,6 +820,7 @@ function setMatchPeriod(period) {
|
|||||||
addPeriodMarker("period_start_1h", "Начало 1-го тайма", 0);
|
addPeriodMarker("period_start_1h", "Начало 1-го тайма", 0);
|
||||||
triggerVmixTimerAction("first_half_start");
|
triggerVmixTimerAction("first_half_start");
|
||||||
saveMatchState();
|
saveMatchState();
|
||||||
|
syncClockStateToServer(true);
|
||||||
clearExtraTimeInput();
|
clearExtraTimeInput();
|
||||||
updatePeriodButtons();
|
updatePeriodButtons();
|
||||||
return;
|
return;
|
||||||
@@ -725,6 +836,7 @@ function setMatchPeriod(period) {
|
|||||||
addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds);
|
addPeriodMarker("period_ht", "Конец 1-го тайма", actualEndSeconds);
|
||||||
triggerVmixTimerAction("halftime");
|
triggerVmixTimerAction("halftime");
|
||||||
saveMatchState();
|
saveMatchState();
|
||||||
|
syncClockStateToServer(true);
|
||||||
clearExtraTimeInput();
|
clearExtraTimeInput();
|
||||||
updatePeriodButtons();
|
updatePeriodButtons();
|
||||||
return;
|
return;
|
||||||
@@ -747,6 +859,7 @@ function setMatchPeriod(period) {
|
|||||||
triggerVmixTimerAction("second_half_start");
|
triggerVmixTimerAction("second_half_start");
|
||||||
startTimer();
|
startTimer();
|
||||||
saveMatchState();
|
saveMatchState();
|
||||||
|
syncClockStateToServer(true);
|
||||||
updatePeriodButtons();
|
updatePeriodButtons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -757,6 +870,7 @@ function setMatchPeriod(period) {
|
|||||||
addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds());
|
addPeriodMarker("period_ft", "Конец 2-го тайма", getLiveClockSeconds());
|
||||||
triggerVmixTimerAction("full_time");
|
triggerVmixTimerAction("full_time");
|
||||||
saveMatchState();
|
saveMatchState();
|
||||||
|
syncClockStateToServer(true);
|
||||||
clearExtraTimeInput();
|
clearExtraTimeInput();
|
||||||
updatePeriodButtons();
|
updatePeriodButtons();
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1136,7 @@ function clearMatchEvents() {
|
|||||||
renderEvents();
|
renderEvents();
|
||||||
|
|
||||||
saveMatchState();
|
saveMatchState();
|
||||||
|
syncClockStateToServer(true);
|
||||||
clearEventsOnServer();
|
clearEventsOnServer();
|
||||||
updateCoachCardButtons();
|
updateCoachCardButtons();
|
||||||
updatePeriodButtons();
|
updatePeriodButtons();
|
||||||
@@ -2416,6 +2531,7 @@ if (!restored) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadEventsFromServer();
|
loadEventsFromServer();
|
||||||
|
initClockStateSync();
|
||||||
window.addEventListener("beforeunload", saveMatchState);
|
window.addEventListener("beforeunload", saveMatchState);
|
||||||
window.addEventListener("pagehide", saveMatchState);
|
window.addEventListener("pagehide", saveMatchState);
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user