CoolFace
Apppublic

Rmurphy92/MurphmentumVolleyballPhone

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
app.py2675 linesDownload Raw Back to root
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),

Showing the first 1,200 of 2675 lines. Download the file for the rest.