Rmurphy92/MurphmentumVolleyballPhone
0
1# app.py — Volleyball Live Stat + Flow (Club Modern, iPhone-first)2# Stable on HF Spaces: no heavy PDF/plot on every rerun3# - Sidebar menu (Live / Lineups / Insights)4# - Flow always under scoreboard (cached PNG)5# - Team=green, Opposition=red6# - Timeouts: 2 per set (including tiebreak), tracked for both teams + shown in UI/PDF/flow7# - Autosave (incl images) to /data8# - Logos + court background + watermark (optional)9# - PDF per set includes score, flow, lineups, insights10 11import os12import io13import json14import base6415from datetime import datetime16 17import streamlit as st18import pandas as pd19import matplotlib.pyplot as plt20 21from reportlab.lib.pagesizes import A422from reportlab.pdfgen import canvas23from reportlab.lib.units import cm24from reportlab.lib.utils import ImageReader25 26# -----------------------27# PAGE CONFIG28# -----------------------29st.set_page_config(30 page_title="Volleyball Live Flow",31 layout="wide",32 initial_sidebar_state="collapsed", # phone-first33)34 35# -----------------------36# STYLE (Club Modern)37# -----------------------38st.markdown(39 """40<style>41:root{42 --card-brd: rgba(255,255,255,0.16);43 --card-bg1: rgba(255,255,255,0.10);44 --card-bg2: rgba(255,255,255,0.06);45 --soft: rgba(255,255,255,0.10);46 47 --club-a: #7C3AED; /* purple */48 --club-b: #06B6D4; /* cyan */49 --club-c: #22C55E; /* green */50 --club-d: #EF4444; /* red */51}52 53.block-container{ padding-top: 0.75rem; padding-bottom: 2rem; }54h1, h2, h3 { letter-spacing: -0.02em; }55hr { opacity: 0.25; }56 57.stApp{58 background:59 radial-gradient(900px 500px at 20% 0%, rgba(124,58,237,0.18), transparent 55%),60 radial-gradient(700px 450px at 85% 10%, rgba(6,182,212,0.16), transparent 55%),61 radial-gradient(900px 550px at 50% 110%, rgba(34,197,94,0.10), transparent 55%);62}63 64/* Cards */65.card, .score-wrap{66 border: 1px solid var(--card-brd);67 background: linear-gradient(180deg, var(--card-bg1), var(--card-bg2));68 border-radius: 18px;69 padding: 10px 12px;70 box-shadow:71 0 12px 34px rgba(0,0,0,0.22),72 0 0 0 1px rgba(255,255,255,0.06) inset;73}74 75/* Sticky Scoreboard */76.score-wrap{77 position: relative;78 overflow: hidden;79 margin-bottom: 8px;80}81.score-wrap:before{82 content:"";83 position:absolute; inset:-2px;84 background: linear-gradient(90deg, rgba(124,58,237,0.55), rgba(6,182,212,0.55), rgba(34,197,94,0.40));85 filter: blur(14px);86 opacity: 0.50;87 pointer-events:none;88}89.score-wrap > *{ position: relative; z-index: 1; }90.sticky-score{91 position: sticky;92 top: 0.5rem;93 z-index: 999;94 backdrop-filter: blur(10px);95}96.sticky-spacer{ height: 0.2rem; }97 98.score-mid{ text-align:center; }99 100/* Pills */101.pill{102 display:inline-block; padding: 6px 10px; border-radius: 999px;103 border:1px solid rgba(255,255,255,0.18);104 background: rgba(255,255,255,0.10);105 font-size: 12px; font-weight: 900;106 margin: 4px 4px 0 4px;107 box-shadow: 0 6px 18px rgba(0,0,0,0.18);108}109 110/* BIG score numbers (scoreboard) */111.big-metric [data-testid="stMetricValue"]{112 font-size: 190px !important;113 font-weight: 950 !important;114 line-height: 1.0 !important;115}116.big-metric [data-testid="stMetricLabel"]{117 font-size: 16px !important;118 font-weight: 900 !important;119 opacity: 0.92 !important;120}121@media (max-width: 520px){122 .big-metric [data-testid="stMetricValue"]{123 font-size: 150px !important;124 }125}126 127.big-metric [data-testid="stMetricLabel"]{128 font-size: 14px !important;129 font-weight: 850 !important;130 opacity: 0.92 !important;131}132 133/* Insights key metrics */134.ins-metric [data-testid="stMetricValue"]{135 font-size: 34px !important;136 font-weight: 950 !important;137}138.ins-metric [data-testid="stMetricLabel"]{139 font-size: 13px !important;140 font-weight: 900 !important;141 opacity: 0.9 !important;142}143 144/* Buttons: modern */145div[data-testid^="stBaseButton-"] button{146 border: 1px solid rgba(255,255,255,0.16) !important;147 background: rgba(255,255,255,0.08) !important;148 box-shadow: 0 10px 24px rgba(0,0,0,0.20);149}150div[data-testid^="stBaseButton-"] button:hover{151 transform: translateY(-1px);152 transition: 0.12s ease;153}154 155/* Winner buttons */156.win-grid div[data-testid^="stBaseButton-"] button{157 padding: 0.78rem 0.9rem !important;158 font-size: 1.05rem !important;159 font-weight: 950 !important;160 border-radius: 16px !important;161 background: linear-gradient(135deg, rgba(124,58,237,0.22), rgba(6,182,212,0.18)) !important;162}163 164/* HOW grid buttons */165.how-grid div[data-testid^="stBaseButton-"] button{166 padding: 0.20rem 0.34rem !important;167 font-size: 0.74rem !important;168 font-weight: 950 !important;169 line-height: 1.05 !important;170 border-radius: 12px !important;171 min-height: 2.05rem !important;172}173 174/* Small icon row buttons */175.icon-btn div[data-testid^="stBaseButton-"] button{176 padding: 0.52rem 0.70rem !important;177 border-radius: 14px !important;178 font-weight: 950 !important;179}180 181/* Lineup net */182.lineup-grid input{183 text-align: center !important;184 font-weight: 950 !important;185 font-size: 1.05rem !important;186 padding: 0.35rem 0.4rem !important;187}188.pos-chip{189 display:inline-block;190 padding: 4px 8px;191 border-radius: 999px;192 border: 1px solid rgba(255,255,255,0.18);193 background: rgba(255,255,255,0.10);194 font-size: 12px;195 font-weight: 950;196 margin-bottom: 6px;197}198.net-label{199 text-align:center; font-weight: 950; letter-spacing: 0.12em; opacity: 0.9;200 margin-bottom: 8px;201}202.row-label{203 font-size: 12px; font-weight: 950; opacity: 0.85; margin: 6px 0 2px 0;204}205.small-help{ font-size: 12px; opacity: 0.85; }206 207@media (max-width: 520px){208 .big-metric [data-testid="stMetricValue"]{209 font-size: 140px !important;210 font-weight: 950 !important;211 line-height: 1.0 !important;212}213.big-metric [data-testid="stMetricLabel"]{214 font-size: 16px !important;215}216@media (max-width: 520px){217 .big-metric [data-testid="stMetricValue"]{218 font-size: 120px !important;219 }220}221 222 .score-wrap{ padding: 9px 9px; }223}224/* Broadcast scoreboard (horizontal) */225.broadcast-wrap{226 position: sticky;227 top: 0.5rem;228 z-index: 999;229 margin-bottom: 10px;230 border: 1px solid rgba(255,255,255,0.16);231 background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));232 border-radius: 18px;233 padding: 10px 12px;234 box-shadow: 0 12px 34px rgba(0,0,0,0.22), 0 0 0 1px rgba(255,255,255,0.06) inset;235 backdrop-filter: blur(10px);236 overflow: hidden;237}238 239.broadcast-row{240 display: flex;241 align-items: center;242 justify-content: space-between;243 gap: 10px;244 flex-wrap: nowrap;245}246 247.team-side{248 display: flex;249 align-items: center;250 gap: 10px;251 min-width: 0;252}253 254.team-logo{255 width: 34px;256 height: 34px;257 border-radius: 10px;258 object-fit: cover;259 flex: 0 0 auto;260 border: 1px solid rgba(255,255,255,0.16);261 background: rgba(255,255,255,0.08);262}263 264.team-name{265 font-weight: 950;266 font-size: 14px;267 line-height: 1.05;268 white-space: nowrap;269 overflow: hidden;270 text-overflow: ellipsis;271 max-width: 42vw;272}273 274.team-score{275 font-weight: 950;276 font-size: 34px;277 line-height: 1;278 padding: 2px 10px;279 border-radius: 12px;280 background: rgba(255,255,255,0.10);281 border: 1px solid rgba(255,255,255,0.16);282 flex: 0 0 auto;283}284 285.mid-block{286 text-align: center;287 flex: 0 0 auto;288 padding: 0 6px;289}290 291.mid-top{292 font-weight: 950;293 letter-spacing: 0.08em;294 font-size: 12px;295 opacity: 0.95;296}297 298.mid-sub{299 font-weight: 850;300 font-size: 12px;301 opacity: 0.90;302 margin-top: 2px;303}304 305.serve-dot{306 display:inline-block;307 width: 10px;308 height: 10px;309 border-radius: 999px;310 margin-right: 6px;311 border: 1px solid rgba(255,255,255,0.28);312 vertical-align: middle;313}314 315.dot-team{ background: #22C55E; }316.dot-opp{ background: #EF4444; }317 318.broadcast-meta{319 margin-top: 8px;320 display: flex;321 align-items: center;322 justify-content: space-between;323 gap: 10px;324 flex-wrap: wrap;325}326 327.meta-pill{328 display:inline-flex;329 align-items:center;330 gap: 6px;331 padding: 6px 10px;332 border-radius: 999px;333 border: 1px solid rgba(255,255,255,0.18);334 background: rgba(255,255,255,0.10);335 font-size: 12px;336 font-weight: 900;337 box-shadow: 0 6px 18px rgba(0,0,0,0.18);338 white-space: nowrap;339}340 341@media (max-width: 420px){342 .team-score{ font-size: 30px; padding: 2px 8px; }343 .team-name{ font-size: 13px; max-width: 38vw; }344 .team-logo{ width: 30px; height: 30px; }345}346 347/* Phone-first: reduce padding + make tabs/buttons feel app-like */348@media (max-width: 520px){349 .block-container{350 padding-top: 0.35rem !important;351 padding-bottom: 0.75rem !important;352 padding-left: 0.60rem !important;353 padding-right: 0.60rem !important;354 }355 h1{ font-size: 1.20rem !important; margin-bottom: 0.25rem !important; }356 h2{ font-size: 1.02rem !important; margin-bottom: 0.25rem !important; }357 h3{ font-size: 0.94rem !important; margin-bottom: 0.20rem !important; }358}359 360div[data-baseweb="tab"]{ font-weight: 900; }361 362/* 3×3 live control pad */363.control-grid div[data-testid^="stBaseButton-"] button{364 padding: 0.85rem 0.65rem !important;365 border-radius: 16px !important;366 font-weight: 950 !important;367 font-size: 0.95rem !important;368 min-height: 3.1rem !important;369}370 371/* 3×3 scoring (how) pad */372.how-grid div[data-testid^="stBaseButton-"] button{373 padding: 0.75rem 0.55rem !important;374 font-size: 0.85rem !important;375 font-weight: 950 !important;376 line-height: 1.05 !important;377 border-radius: 14px !important;378 min-height: 3.0rem !important;379}380</style>381""",382 unsafe_allow_html=True,383)384 385# -----------------------386# CONSTANTS387# -----------------------388# Full-name mapping for flow + PDF389KEYS = [390 ("A", "Ace"),391 ("SE", "Service error (opposition)"),392 ("K", "Kill (attack)"),393 ("HE", "Hitting error (opposition)"),394 ("B", "Block"),395 ("OE", "Opposition error"),396 ("F", "Fault / violation"),397 ("BH", "Handling error"),398 ("O", "Other"),399]400KEY_MAP = {c: w for c, w in KEYS}401 402EU_ROTATION = [1, 6, 5, 4, 3, 2] # setter positions rotate: 1→6→5→4→3→2→1403ROT_NUMS = [1, 2, 3, 4, 5, 6]404FRONT_ROW = [4, 3, 2]405BACK_ROW = [5, 6, 1]406 407TIMEOUTS_PER_SET = 2408 409SAVE_DIR = "/data"410import uuid411 412B64_KEYS = {"pdf_bytes", "logo_team", "logo_opp", "bg_court", "wm_ball"}413# =========================414# MATCH SYSTEM (WORKING + SIMPLE)415# =========================416 417def _get_query_params():418 try:419 return dict(st.query_params)420 except Exception:421 return st.experimental_get_query_params()422 423def _set_query_params(**kwargs):424 try:425 clean = {k: ("" if v is None else v) for k, v in kwargs.items()}426 st.query_params.update(clean)427 except Exception:428 clean = {k: ("" if v is None else v) for k, v in kwargs.items()}429 st.experimental_set_query_params(**clean)430 431def match_path(mid: str) -> str:432 return os.path.join(SAVE_DIR, f"match_{mid}.json")433 434def match_exists(mid: str) -> bool:435 return mid and os.path.exists(match_path(mid))436 437def create_new_match_file(mid: str):438 os.makedirs(SAVE_DIR, exist_ok=True)439 path = match_path(mid)440 if not os.path.exists(path):441 with open(path, "w", encoding="utf-8") as f:442 json.dump({}, f)443 444def list_saved_matches():445 try:446 if not os.path.exists(SAVE_DIR):447 return []448 out = []449 for name in os.listdir(SAVE_DIR):450 if name.startswith("match_") and name.endswith(".json"):451 mid = name[len("match_"):-5]452 path = os.path.join(SAVE_DIR, name)453 mtime = os.path.getmtime(path)454 out.append({"match_id": mid, "mtime": mtime})455 out.sort(key=lambda x: x["mtime"], reverse=True)456 return out457 except Exception:458 return []459 460def delete_match_file(mid: str):461 try:462 path = match_path(mid)463 if os.path.exists(path):464 os.remove(path)465 return True466 except Exception as e:467 st.error(e)468 return False469 470# 1) Load match_id from URL if present471qp = _get_query_params()472url_mid = qp.get("match")473if isinstance(url_mid, list):474 url_mid = url_mid[0] if url_mid else None475if url_mid:476 st.session_state.match_id = str(url_mid)477 478# 2) If match_id exists but file missing → reset479if st.session_state.get("match_id") and not match_exists(st.session_state.match_id):480 st.session_state.match_id = None481 _set_query_params(match=None)482 483# 3) If no match_id → show chooser484if not st.session_state.get("match_id"):485 st.title("🏐 Volleyball Live Flow")486 st.markdown("### Start new match or load an existing one")487 488 saved = list_saved_matches()489 490 if saved:491 ids = [x["match_id"] for x in saved]492 pick_id = st.selectbox("Saved matches", ids)493 494 c1, c2, c3 = st.columns(3)495 with c1:496 if st.button("▶️ Load", use_container_width=True):497 st.session_state.match_id = pick_id498 _set_query_params(match=pick_id)499 st.rerun()500 501 with c2:502 if st.button("↩️ Latest", use_container_width=True):503 latest = ids[0]504 st.session_state.match_id = latest505 _set_query_params(match=latest)506 st.rerun()507 508 with c3:509 if st.button("🗑️ Delete", use_container_width=True):510 if delete_match_file(pick_id):511 st.success("Deleted.")512 st.rerun()513 514 st.divider()515 516 if st.button("🆕 Start new match", use_container_width=True):517 new_id = uuid.uuid4().hex[:8]518 create_new_match_file(new_id) # ✅ critical line519 st.session_state.match_id = new_id520 _set_query_params(match=new_id)521 st.rerun()522 523 st.stop()524 525# 4) Now SAVE_PATH is safe to define526SAVE_PATH = match_path(st.session_state.match_id)527 528def list_saved_matches():529 """Return newest-first list of saved match files with ids."""530 try:531 if not os.path.exists(SAVE_DIR):532 return []533 out = []534 for name in os.listdir(SAVE_DIR):535 if name.startswith("match_") and name.endswith(".json"):536 path = os.path.join(SAVE_DIR, name)537 match_id = name[len("match_"):-len(".json")]538 try:539 mtime = os.path.getmtime(path)540 except Exception:541 mtime = 0542 out.append({"path": path, "mtime": mtime, "match_id": match_id})543 out.sort(key=lambda d: d["mtime"], reverse=True)544 return out545 except Exception:546 return []547def load_match_from_id(match_id: str) -> bool:548 """Load /data/match_<id>.json into session_state. Returns True/False.549 Supports BOTH save formats:550 - legacy: { ...state... }551 - newer: { "state": {...}, "meta": {...} }552 """553 try:554 path = os.path.join(SAVE_DIR, f"match_{match_id}.json")555 if not os.path.exists(path):556 return False557 558 with open(path, "r", encoding="utf-8") as f:559 obj = json.load(f)560 561 # ✅ support both formats562 if isinstance(obj, dict) and "state" in obj and isinstance(obj["state"], dict):563 state = obj.get("state", {}) or {}564 else:565 state = obj if isinstance(obj, dict) else {}566 567 for k, v in state.items():568 if k in B64_KEYS:569 st.session_state[k] = b64d(v)570 else:571 st.session_state[k] = v572 573 st.session_state.match_id = match_id574 return True575 576 except Exception as e:577 st.error(f"Load error: {e}") # temporary debug578 return False579 580def _get_query_params():581 try:582 return dict(st.query_params)583 except Exception:584 return st.experimental_get_query_params()585 586def _set_query_params(**kwargs):587 try:588 st.query_params.update(kwargs)589 except Exception:590 st.experimental_set_query_params(**kwargs)591def delete_match_file(match_id: str) -> bool:592 """Delete match_<id>.json (and any temp file)."""593 try:594 path = os.path.join(SAVE_DIR, f"match_{match_id}.json")595 tmp_path = path + ".tmp"596 if os.path.exists(tmp_path):597 os.remove(tmp_path)598 if os.path.exists(path):599 os.remove(path)600 return True601 except Exception as e:602 st.error(f"Delete error: {e}")603 return False604 605 606 607SAVE_PATH = os.path.join(SAVE_DIR, f"match_{st.session_state.match_id}.json")608def read_match_meta(match_id: str) -> dict:609 """Lightweight metadata for UI labels (no base64 decode)."""610 path = os.path.join(SAVE_DIR, f"match_{match_id}.json")611 meta = {612 "match_id": match_id,613 "match_name": "",614 "team1": "Team",615 "team2": "Opposition",616 "set_no": 1,617 "set_t1": 0,618 "set_t2": 0,619 "updated": "",620 }621 622 try:623 if not os.path.exists(path):624 return meta625 626 meta["updated"] = datetime.fromtimestamp(os.path.getmtime(path)).strftime("%Y-%m-%d %H:%M")627 628 with open(path, "r", encoding="utf-8") as f:629 obj = json.load(f)630 631 # supports legacy {state...} and newer {"state":..., "meta":...}632 if isinstance(obj, dict) and "state" in obj and isinstance(obj["state"], dict):633 state = obj.get("state", {}) or {}634 file_meta = obj.get("meta", {}) or {}635 else:636 state = obj if isinstance(obj, dict) else {}637 file_meta = {}638 639 meta["match_name"] = (file_meta.get("match_name") or "").strip()640 meta["team1"] = state.get("team1", meta["team1"])641 meta["team2"] = state.get("team2", meta["team2"])642 meta["set_no"] = int(state.get("set_no", meta["set_no"]))643 644 # compute current set score from points list645 # ⚡ fast mode: don’t scan points (avoids slowdown)646 meta["set_t1"], meta["set_t2"] = 0, 0647 648 return meta649 except Exception:650 return meta651 652 653def pretty_match_label(m: dict) -> str:654 name = (m.get("match_name") or "").strip()655 t1 = m.get("team1", "Team")656 t2 = m.get("team2", "Opposition")657 s = m.get("set_no", 1)658 sc = f"{m.get('set_t1', 0)}–{m.get('set_t2', 0)}"659 upd = m.get("updated", "")660 base = f"{t1} vs {t2} • Set {s} • {sc} • {upd}"661 return f"{name} • {base}" if name else base662 663# -----------------------664# BYTES <-> BASE64 (autosave-safe)665# -----------------------666def b64e(b):667 if not b:668 return None669 return base64.b64encode(b).decode("utf-8")670 671def b64d(s):672 if not s:673 return None674 try:675 return base64.b64decode(s.encode("utf-8"))676 except Exception:677 return None678 679# -----------------------680# SAVE / LOAD681# -----------------------682def export_match_json() -> bytes:683 payload = {684 "version": 1,685 "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),686 "state": {687 "phase": st.session_state.get("phase", "match_setup"),688 "team1": st.session_state.get("team1", "Team"),689 "team2": st.session_state.get("team2", "Opposition"),690 "format_mode": st.session_state.get("format_mode", "Best of"),691 "best_of_sets": st.session_state.get("best_of_sets", 5),692 "total_sets": st.session_state.get("total_sets", 3),693 "points_to": st.session_state.get("points_to", 25),694 "win_by_2": st.session_state.get("win_by_2", True),695 "use_final_set_points": st.session_state.get("use_final_set_points", True),696 "final_set_points_to": st.session_state.get("final_set_points_to", 15),697 698 "set_no": st.session_state.get("set_no", 1),699 "sets_won_team1": st.session_state.get("sets_won_team1", 0),700 "sets_won_team2": st.session_state.get("sets_won_team2", 0),701 "match_over": st.session_state.get("match_over", False),702 703 "serving_team": st.session_state.get("serving_team", st.session_state.get("team1", "Team")),704 "setter_pos_team1": st.session_state.get("setter_pos_team1", 1),705 "setter_pos_team2": st.session_state.get("setter_pos_team2", 1),706 "pending_winner": None,707 708 "set_starts": st.session_state.get("set_starts", {}),709 "points": st.session_state.get("points", []),710 "timeouts": st.session_state.get("timeouts", {}),711 },712 "images": {713 "logo_team": b64e(st.session_state.get("logo_team", None)),714 "logo_opp": b64e(st.session_state.get("logo_opp", None)),715 "bg_court": b64e(st.session_state.get("bg_court", None)),716 "wm_ball": b64e(st.session_state.get("wm_ball", None)),717 }718 }719 return json.dumps(payload).encode("utf-8")720 721 722def import_match_json(file_bytes: bytes):723 obj = json.loads(file_bytes.decode("utf-8"))724 state = obj.get("state", {})725 images = obj.get("images", {})726 727 for k, v in state.items():728 st.session_state[k] = v729 730 st.session_state.logo_team = b64d(images.get("logo_team"))731 st.session_state.logo_opp = b64d(images.get("logo_opp"))732 st.session_state.bg_court = b64d(images.get("bg_court"))733 st.session_state.wm_ball = b64d(images.get("wm_ball"))734 735 st.session_state.pending_winner = None736 737 # Refresh SAVE_PATH after importing match_id738 global SAVE_PATH739 SAVE_PATH = os.path.join(SAVE_DIR, f"match_{st.session_state.match_id}.json")740 741B64_KEYS = {"pdf_bytes", "logo_team", "logo_opp", "bg_court", "wm_ball"}742 743def autosave():744 try:745 os.makedirs(SAVE_DIR, exist_ok=True)746 747 state = {}748 for k, v in st.session_state.items():749 if k in (750 "phase","team1","team2","format_mode","best_of_sets","total_sets",751 "points_to","win_by_2","use_final_set_points","final_set_points_to",752 "set_no","sets_won_team1","sets_won_team2","match_over",753 "serving_team","setter_pos_team1","setter_pos_team2","pending_winner",754 "set_starts","points","timeouts","sidebar_view",755 "pdf_bytes","pdf_stamp",756 "logo_team","logo_opp","bg_court","wm_ball",757 "match_id"758 ):759 if k in B64_KEYS:760 state[k] = b64e(v)761 else:762 state[k] = v763 764 # ✅ Atomic write: write temp then replace765 tmp_path = SAVE_PATH + ".tmp"766 with open(tmp_path, "w", encoding="utf-8") as f:767 json.dump(state, f)768 os.replace(tmp_path, SAVE_PATH)769 770 except Exception:771 # keep silent — never crash the UI772 pass773 774SAVE_PATH = os.path.join(SAVE_DIR, f"match_{st.session_state.match_id}.json")775 776def autoload():777 try:778 if not os.path.exists(SAVE_PATH):779 return780 781 with open(SAVE_PATH, "r", encoding="utf-8") as f:782 obj = json.load(f)783 784 # ✅ support both formats785 if isinstance(obj, dict) and "state" in obj and isinstance(obj["state"], dict):786 state = obj.get("state", {}) or {}787 else:788 state = obj if isinstance(obj, dict) else {}789 790 for k, v in state.items():791 if k in B64_KEYS:792 st.session_state[k] = b64d(v)793 else:794 st.session_state[k] = v795 796 except Exception as e:797 st.error(f"Autoload error: {e}") # temporary debug798autoload()799 800 801# -----------------------802# DEFAULTS803# -----------------------804if "phase" not in st.session_state:805 st.session_state.phase = "match_setup"806if "team1" not in st.session_state: st.session_state.team1 = "Team"807if "team2" not in st.session_state: st.session_state.team2 = "Opposition"808 809if "format_mode" not in st.session_state: st.session_state.format_mode = "Best of"810if "best_of_sets" not in st.session_state: st.session_state.best_of_sets = 5811if "total_sets" not in st.session_state: st.session_state.total_sets = 3812 813if "points_to" not in st.session_state: st.session_state.points_to = 25814if "win_by_2" not in st.session_state: st.session_state.win_by_2 = True815if "use_final_set_points" not in st.session_state: st.session_state.use_final_set_points = True816if "final_set_points_to" not in st.session_state: st.session_state.final_set_points_to = 15817 818if "set_no" not in st.session_state: st.session_state.set_no = 1819if "sets_won_team1" not in st.session_state: st.session_state.sets_won_team1 = 0820if "sets_won_team2" not in st.session_state: st.session_state.sets_won_team2 = 0821if "match_over" not in st.session_state: st.session_state.match_over = False822 823if "serving_team" not in st.session_state: st.session_state.serving_team = st.session_state.team1824if "setter_pos_team1" not in st.session_state: st.session_state.setter_pos_team1 = 1825if "setter_pos_team2" not in st.session_state: st.session_state.setter_pos_team2 = 1826if "pending_winner" not in st.session_state: st.session_state.pending_winner = None827 828if "set_starts" not in st.session_state: st.session_state.set_starts = {}829if "points" not in st.session_state: st.session_state.points = []830if "timeouts" not in st.session_state: st.session_state.timeouts = {}831if "sidebar_view" not in st.session_state: st.session_state.sidebar_view = "Live"832 833if "pdf_bytes" not in st.session_state: st.session_state.pdf_bytes = None834if "pdf_stamp" not in st.session_state: st.session_state.pdf_stamp = None835 836if "logo_team" not in st.session_state: st.session_state.logo_team = None837if "logo_opp" not in st.session_state: st.session_state.logo_opp = None838if "bg_court" not in st.session_state: st.session_state.bg_court = None839if "wm_ball" not in st.session_state: st.session_state.wm_ball = None840 841# -----------------------842# Inject optional images into CSS (court bg + watermark)843# -----------------------844def b64img(img_bytes):845 return base64.b64encode(img_bytes).decode("utf-8") if img_bytes else ""846 847bg = b64img(st.session_state.bg_court)848wm = b64img(st.session_state.wm_ball)849extra_css = "<style>"850if bg:851 extra_css += f"""852 .card.court-bg {{853 background-image:854 linear-gradient(180deg, rgba(0,0,0,0.35), rgba(0,0,0,0.55)),855 url("data:image/png;base64,{bg}") !important;856 background-size: cover !important;857 background-position: center !important;858 }}"""859if wm:860 extra_css += f"""861 .score-wrap.wm {{862 background-image:863 linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.05)),864 url("data:image/png;base64,{wm}") !important;865 background-size: cover !important;866 background-position: center !important;867 }}"""868extra_css += "</style>"869st.markdown(extra_css, unsafe_allow_html=True)870 871# -----------------------872# HELPERS873# -----------------------874def rotate_eu(pos: int) -> int:875 idx = EU_ROTATION.index(int(pos))876 return EU_ROTATION[(idx + 1) % 6]877 878def sets_needed(best_of_sets: int) -> int:879 return (best_of_sets // 2) + 1880 881def max_sets_for_match() -> int:882 return int(st.session_state.best_of_sets) if st.session_state.format_mode == "Best of" else int(st.session_state.total_sets)883 884def is_final_or_deciding_set(set_no: int) -> bool:885 return set_no == max_sets_for_match()886 887def points_target_for_set(set_no: int) -> int:888 if st.session_state.use_final_set_points and is_final_or_deciding_set(set_no):889 return int(st.session_state.final_set_points_to)890 return int(st.session_state.points_to)891 892def get_set_score(points, team1, team2, set_no: int):893 t1 = sum(1 for p in points if p.get("set") == set_no and (not p.get("is_event", False)) and p.get("winner") == team1)894 t2 = sum(1 for p in points if p.get("set") == set_no and (not p.get("is_event", False)) and p.get("winner") == team2)895 return t1, t2896 897def check_set_over(set_no: int, set_t1: int, set_t2: int) -> bool:898 target = points_target_for_set(set_no)899 if set_t1 >= target or set_t2 >= target:900 if not st.session_state.win_by_2:901 return set_t1 != set_t2902 return abs(set_t1 - set_t2) >= 2903 return False904 905def ensure_set_configured(set_no: int) -> bool:906 return str(set_no) in st.session_state.set_starts907 908def lineup_from_inputs(prefix: str, set_no: int) -> dict:909 return {str(pos): (st.session_state.get(f"{prefix}_p{pos}_s{set_no}", "") or "").strip() for pos in ROT_NUMS}910 911def init_timeouts_for_set(set_no: int):912 team1 = st.session_state.team1913 team2 = st.session_state.team2914 st.session_state.timeouts[str(set_no)] = {team1: 0, team2: 0}915 916def record_set_start(set_no: int, serves_first: str, sp1: int, sp2: int, lineup1: dict, lineup2: dict):917 st.session_state.set_starts[str(set_no)] = {918 "serves_first": serves_first,919 "setter_pos_team1_start": int(sp1),920 "setter_pos_team2_start": int(sp2),921 "lineup_team1": lineup1,922 "lineup_team2": lineup2,923 "time": datetime.now().strftime("%H:%M:%S"),924 }925 init_timeouts_for_set(set_no)926 927 st.session_state.serving_team = serves_first928 st.session_state.setter_pos_team1 = int(sp1)929 st.session_state.setter_pos_team2 = int(sp2)930 st.session_state.pending_winner = None931 autosave()932 933def set_winner_from_score(team1, team2, set_t1, set_t2):934 return team1 if set_t1 > set_t2 else team2935 936def maybe_finish_match():937 if st.session_state.format_mode == "Best of":938 need = sets_needed(st.session_state.best_of_sets)939 if st.session_state.sets_won_team1 >= need or st.session_state.sets_won_team2 >= need:940 st.session_state.match_over = True941 st.session_state.phase = "match_end"942 else:943 if st.session_state.set_no > st.session_state.total_sets:944 st.session_state.match_over = True945 st.session_state.phase = "match_end"946 947def advance_to_next_set():948 st.session_state.set_no += 1949 if st.session_state.set_no > max_sets_for_match():950 st.session_state.match_over = True951 st.session_state.phase = "match_end"952 else:953 st.session_state.phase = "set_setup"954 autosave()955 956def _next_seq_in_set(set_no: int) -> int:957 return sum(1 for p in st.session_state.points if p.get("set") == set_no) + 1958 959def _next_rally_in_set(set_no: int) -> int:960 return sum(1 for p in st.session_state.points if p.get("set") == set_no and (not p.get("is_event", False))) + 1961def _img_data_uri(img_bytes: bytes | None) -> str:962 if not img_bytes:963 return ""964 b64 = base64.b64encode(img_bytes).decode("utf-8")965 # assume png unless it looks like jpeg966 is_jpg = img_bytes[:3] == b"\xff\xd8\xff"967 mime = "image/jpeg" if is_jpg else "image/png"968 return f"data:{mime};base64,{b64}"969 970def _recompute_state_from_points():971 """Rebuild serving/rotations/timeouts for the CURRENT set from set_starts + points log."""972 team1 = st.session_state.team1973 team2 = st.session_state.team2974 s = int(st.session_state.set_no)975 set_key = str(s)976 977 ss = (st.session_state.set_starts or {}).get(set_key, {}) or {}978 979 # reset current set timeouts980 if "timeouts" not in st.session_state or not isinstance(st.session_state.timeouts, dict):981 st.session_state.timeouts = {}982 st.session_state.timeouts[set_key] = {team1: 0, team2: 0}983 984 # start-of-set state985 serving = ss.get("serves_first", team1)986 sp1 = int(ss.get("setter_pos_team1_start", 1))987 sp2 = int(ss.get("setter_pos_team2_start", 1))988 989 # replay only THIS set (seq order)990 pts = [p for p in st.session_state.points if int(p.get("set", 0)) == s]991 pts.sort(key=lambda p: int(p.get("seq", 0)))992 993 for p in pts:994 # timeout event995 if p.get("is_event", False) and p.get("code") == "TO":996 ev_team = p.get("event_team")997 if ev_team in st.session_state.timeouts[set_key]:998 st.session_state.timeouts[set_key][ev_team] += 1999 continue1000 1001 # scoring rally1002 winner = p.get("winner")1003 if not winner:1004 continue1005 1006 side_out = (winner != serving)1007 if side_out:1008 if winner == team1:1009 sp1 = rotate_eu(sp1)1010 elif winner == team2:1011 sp2 = rotate_eu(sp2)1012 serving = winner1013 1014 st.session_state.serving_team = serving1015 st.session_state.setter_pos_team1 = sp11016 st.session_state.setter_pos_team2 = sp21017 1018 1019def undo_last_action_easy():1020 """Undo the most recent log entry (rally OR timeout) in the CURRENT set."""1021 if not st.session_state.points:1022 return False, "Nothing to undo."1023 1024 s = int(st.session_state.set_no)1025 1026 # remove last item belonging to this set (so you don't undo an older set by accident)1027 for i in range(len(st.session_state.points) - 1, -1, -1):1028 if int(st.session_state.points[i].get("set", 0)) == s:1029 st.session_state.points.pop(i)1030 st.session_state.pending_winner = None1031 _recompute_state_from_points()1032 autosave()1033 return True, "Undid last action."1034 1035 return False, "No actions to undo in this set."1036def render_broadcast_scoreboard(1037 team1,1038 team2,1039 set_no,1040 set_t1,1041 set_t2,1042 target_now,1043 win_by_2,1044 serving_team,1045 sp1,1046 sp2,1047 to1,1048 to2,1049 mom,1050 logo_team_bytes,1051 logo_opp_bytes,1052 df,1053):1054 # =========================1055 # FLOW GRAPH (always below scoreboard)1056 # =========================1057 st.markdown('<div id="flow"></div>', unsafe_allow_html=True)1058 1059 df_set = df[df["set"] == set_no].copy() if not df.empty else pd.DataFrame()1060 1061 if df_set.empty:1062 st.caption("Flow will appear after the first rally/timeout.")1063 else:1064 # IMPORTANT: cache key must be stable1065 if "seq" in df_set.columns:1066 df_set = df_set.sort_values("seq")1067 1068 df_json = df_set.to_json(orient="records") # ✅ stable for pd.read_json1069 png = flow_png_cached(df_json, team1, team2, s)1070 st.image(png, use_container_width=True)1071 t1_uri = _img_data_uri(logo_team_bytes)1072 t2_uri = _img_data_uri(logo_opp_bytes)1073 1074 dot_class = "dot-team" if serving_team == team1 else "dot-opp"1075 target_text = f"First to {target_now}" + (" (win by 2)" if win_by_2 else "")1076 1077 left_logo = f'<img class="team-logo" src="{t1_uri}"/>' if t1_uri else '<div class="team-logo"></div>'1078 right_logo = f'<img class="team-logo" src="{t2_uri}"/>' if t2_uri else '<div class="team-logo"></div>'1079 1080 mom_html = f'<span class="meta-pill">Momentum: {mom}</span>' if mom else ""1081 1082 html = f"""<div class="broadcast-wrap">1083<div class="broadcast-row">1084 <div class="team-side">1085 {left_logo}1086 <div class="team-name">{team1}</div>1087 <div class="team-score">{set_t1}</div>1088 </div>1089 1090 <div class="mid-block">1091 <div class="mid-top">SET {set_no}</div>1092 <div class="mid-sub"><span class="serve-dot {dot_class}"></span>Serving: {serving_team}</div>1093 <div class="mid-sub">{target_text}</div>1094 </div>1095 1096 <div class="team-side" style="justify-content:flex-end;">1097 <div class="team-score">{set_t2}</div>1098 <div class="team-name" style="text-align:right;">{team2}</div>1099 {right_logo}1100 </div>1101</div>1102 1103<div class="broadcast-meta">1104 <span class="meta-pill">{team1} Setter: {sp1}</span>1105 <span class="meta-pill">{team2} Setter: {sp2}</span>1106 <span class="meta-pill">TO {team1}: {to1}/2</span>1107 <span class="meta-pill">TO {team2}: {to2}/2</span>1108 {mom_html}1109</div>1110</div>"""1111 1112 st.markdown(html, unsafe_allow_html=True)1113 1114# -----------------------1115# EVENTS: TIMEOUT1116# -----------------------1117def add_timeout(team: str, notes: str = ""):1118 """Timeout marker event. 2 per set including tiebreak. No score/rotation change."""1119 if st.session_state.match_over or st.session_state.phase != "play":1120 return False, "Not in play."1121 1122 team1 = st.session_state.team11123 team2 = st.session_state.team21124 cur_set = st.session_state.set_no1125 set_key = str(cur_set)1126 1127 if set_key not in st.session_state.timeouts:1128 st.session_state.timeouts[set_key] = {team1: 0, team2: 0}1129 1130 used = int(st.session_state.timeouts[set_key].get(team, 0))1131 if used >= TIMEOUTS_PER_SET:1132 return False, f"{team} already used {TIMEOUTS_PER_SET} timeouts in Set {cur_set}."1133 1134 st.session_state.timeouts[set_key][team] = used + 11135 1136 set_t1, set_t2 = get_set_score(st.session_state.points, team1, team2, cur_set)1137 1138 st.session_state.points.append({1139 "time": datetime.now().strftime("%H:%M:%S"),1140 "set": cur_set,1141 "seq": _next_seq_in_set(cur_set),1142 "rally": None,1143 "winner": None,1144 "code": "TO",1145 "how": "Timeout",1146 "notes": notes,1147 "is_event": True,1148 "event_team": team,1149 1150 "side_out": False,1151 "serving_before": st.session_state.serving_team,1152 "setter_pos_team1_before": st.session_state.setter_pos_team1,1153 "setter_pos_team2_before": st.session_state.setter_pos_team2,1154 "setter_pos_team1_after": st.session_state.setter_pos_team1,1155 "setter_pos_team2_after": st.session_state.setter_pos_team2,1156 "set_team1_score": set_t1,1157 "set_team2_score": set_t2,1158 })1159 autosave()1160 return True, "Timeout logged."1161 1162# -----------------------1163# RALLIES (scoring)1164# -----------------------1165def add_rally(winner_team: str, code: str, notes: str = ""):1166 if st.session_state.match_over or st.session_state.phase != "play":1167 return1168 1169 team1 = st.session_state.team11170 team2 = st.session_state.team21171 cur_set = st.session_state.set_no1172 1173 serving_before = st.session_state.serving_team1174 side_out = (winner_team != serving_before)1175 1176 sp1_before = st.session_state.setter_pos_team11177 sp2_before = st.session_state.setter_pos_team21178 1179 if side_out:1180 # side-out -> winner gains serve AND rotates (EU order)1181 if winner_team == team1:1182 st.session_state.setter_pos_team1 = rotate_eu(st.session_state.setter_pos_team1)1183 else:1184 st.session_state.setter_pos_team2 = rotate_eu(st.session_state.setter_pos_team2)1185 st.session_state.serving_team = winner_team1186 1187 sp1_after = st.session_state.setter_pos_team11188 sp2_after = st.session_state.setter_pos_team21189 1190 set_t1, set_t2 = get_set_score(st.session_state.points, team1, team2, cur_set)1191 if winner_team == team1:1192 set_t1 += 11193 else:1194 set_t2 += 11195 1196 st.session_state.points.append({1197 "time": datetime.now().strftime("%H:%M:%S"),1198 "set": cur_set,1199 "seq": _next_seq_in_set(cur_set),1200 "rally": _next_rally_in_set(cur_set),