diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index d2f604a..376e75b 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -17,7 +17,7 @@ from sqlalchemy import and_, delete, desc, select from .auth_bridge import HockeyUser from .database import HockeyDatabase from .mapping_context import MappingDataService -from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField) +from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle) AGENT_PROTOCOL_VERSION = 1 @@ -2010,6 +2010,394 @@ class VmixAgentHub: }) return {"devices": items} + @staticmethod + def _prepared_inventory_from_row(device: VmixDevice | None) -> dict[str, Any]: + if device is None: + return {} + try: + parsed = json.loads(device.project_inventory_json or "{}") + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + @staticmethod + def _prepared_find_input(inventory: dict[str, Any], *, key: str = "", number: str = "", title: str = "") -> dict[str, Any] | None: + inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else [] + key = str(key or "").strip() + number = str(number or "").strip() + title = str(title or "").strip() + if key: + found = next((item for item in inputs if isinstance(item, dict) and str(item.get("key") or "").strip() == key), None) + if found is not None: + return found + if number: + found = next((item for item in inputs if isinstance(item, dict) and str(item.get("number") or "").strip() == number), None) + if found is not None: + return found + if title: + matches = [item for item in inputs if isinstance(item, dict) and str(item.get("title") or "").strip() == title] + if len(matches) == 1: + return matches[0] + return None + + def _prepared_device_for_user(self, session: Any, user: HockeyUser, requested_device_id: str = "") -> VmixDevice: + requested = str(requested_device_id or "").strip() + if requested: + requested = self.normalise_device_id(requested) + device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id))) + if device is None: + raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту") + return device + rows = list(session.scalars( + select(VmixDevice) + .where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True))) + .order_by(desc(VmixDevice.last_seen_at)) + )) + if not rows: + raise HTTPException(status_code=409, detail="Hockey Agent не выбран") + live = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected)), None) + return live or rows[0] + + @staticmethod + def _prepared_title_payload(row: VmixPreparedTitle, inventory: dict[str, Any] | None = None) -> dict[str, Any]: + try: + fields = json.loads(row.field_values_json or "{}") + if not isinstance(fields, dict): + fields = {} + except Exception: + fields = {} + clone_key = str(row.clone_input_key or "") + clone_number = str(row.clone_input_number or "") + clone_title = str(row.clone_input_title or "") + if inventory: + match = VmixAgentHub._prepared_find_input(inventory, key=clone_key, number=clone_number, title=clone_title) + if match is not None: + clone_key = str(match.get("key") or clone_key) + clone_number = str(match.get("number") or clone_number) + clone_title = str(match.get("title") or clone_title) + return { + "id": row.id, + "name": row.name, + "game_id": row.game_external_id, + "device_id": row.device_uuid, + "source_kind": row.source_kind, + "source_ref": row.source_ref, + "source_input": { + "key": row.source_input_key, + "number": row.source_input_number, + "title": row.source_input_title, + }, + "clone_input": {"key": clone_key, "number": clone_number, "title": clone_title}, + "field_values": fields, + "created_at": row.created_at.isoformat() if row.created_at else "", + "updated_at": row.updated_at.isoformat() if row.updated_at else "", + } + + async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]: + with self.database.session() as session: + device = self._prepared_device_for_user(session, user, device_id) + resolved_game = str(game_id or device.current_match_external_id or "").strip() + query = select(VmixPreparedTitle).where(VmixPreparedTitle.wfl_user_id == user.id) + if resolved_game: + query = query.where(VmixPreparedTitle.game_external_id == resolved_game) + if device.device_uuid: + query = query.where(VmixPreparedTitle.device_uuid == device.device_uuid) + rows = list(session.scalars(query.order_by(desc(VmixPreparedTitle.created_at), desc(VmixPreparedTitle.id)))) + inventory = self._prepared_inventory_from_row(device) + # Refresh clone key/title opportunistically after Agent inventory catches up. + for row in rows: + found = self._prepared_find_input( + inventory, + key=str(row.clone_input_key or ""), + number=str(row.clone_input_number or ""), + title=str(row.clone_input_title or ""), + ) + if found is not None: + row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128] + row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32] + row.clone_input_title = str(found.get("title") or row.clone_input_title or "")[:300] + items = [self._prepared_title_payload(row, inventory) for row in rows] + return { + "device_id": device.device_uuid, + "game_id": resolved_game, + "items": items, + } + + def _prepared_mapping_context(self, session: Any, device: VmixDevice, user: HockeyUser) -> tuple[VmixMappingProfile | None, dict[str, Any], dict[str, Any]]: + profile = self._active_mapping_profile_for_device(session, device) + if profile is None: + return None, {}, {} + assignment = session.scalar( + select(VmixAssignment) + .where(and_(VmixAssignment.device_id == device.id, VmixAssignment.active.is_(True))) + .order_by(desc(VmixAssignment.id)) + ) + context = { + "game_id": str(device.current_match_external_id or ""), + "device_id": device.device_uuid, + "assignment_id": str(device.current_assignment_key or ""), + "tournament_id": str(assignment.tournament_external_id or "") if assignment is not None else "", + } + preference = session.get(UserPreference, user.id) + if preference is not None: + language = str(preference.vmix_language or preference.display_language or "").strip().lower() + if language in {"ru", "en"}: + context["ui_language"] = language + inventory = self._prepared_inventory_from_row(device) + return profile, context, inventory + + async def prepared_mapping_sources(self, user: HockeyUser, *, device_id: str = "", panel_id: str = "") -> dict[str, Any]: + panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64] + with self.database.session() as session: + device = self._prepared_device_for_user(session, user, device_id) + profile, _context, inventory = self._prepared_mapping_context(session, device, user) + if profile is None: + return {"device_id": device.device_uuid, "profile_id": 0, "items": []} + fields = list(session.scalars( + select(VmixMappingField) + .where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True))) + .order_by(VmixMappingField.sort_order, VmixMappingField.id) + )) + if panel_id: + prefix = f"player_select.{panel_id}." + fields = [field for field in fields if str(field.data_key or "").startswith(prefix)] + grouped: dict[str, dict[str, Any]] = {} + for field in fields: + identity = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip() + if not identity: + continue + item = grouped.setdefault(identity, { + "key": str(field.vmix_input_key or ""), + "number": str(field.vmix_input_number or ""), + "title": str(field.vmix_input_title or ""), + "mapped_fields": 0, + }) + item["mapped_fields"] += 1 + for item in grouped.values(): + found = self._prepared_find_input(inventory, key=item["key"], number=item["number"], title=item["title"]) + if found is not None: + item.update({ + "key": str(found.get("key") or item["key"]), + "number": str(found.get("number") or item["number"]), + "title": str(found.get("title") or item["title"]), + "type": str(found.get("type") or ""), + }) + items = sorted(grouped.values(), key=lambda value: (int(value.get("number") or 999999) if str(value.get("number") or "").isdigit() else 999999, str(value.get("title") or "").casefold())) + return {"device_id": device.device_uuid, "profile_id": profile.id, "items": items} + + async def prepared_mapping_snapshot( + self, + user: HockeyUser, + *, + device_id: str = "", + input_key: str = "", + input_number: str = "", + input_title: str = "", + panel_id: str = "", + ) -> dict[str, Any]: + panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64] + with self.database.session() as session: + device = self._prepared_device_for_user(session, user, device_id) + profile, context, inventory = self._prepared_mapping_context(session, device, user) + if profile is None: + return {"device_id": device.device_uuid, "profile_id": 0, "fields": []} + source = self._prepared_find_input(inventory, key=input_key, number=input_number, title=input_title) + source_key = str(source.get("key") or input_key or "") if source else str(input_key or "") + source_number = str(source.get("number") or input_number or "") if source else str(input_number or "") + source_title = str(source.get("title") or input_title or "") if source else str(input_title or "") + fields = list(session.scalars( + select(VmixMappingField) + .where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True))) + .order_by(VmixMappingField.sort_order, VmixMappingField.id) + )) + if panel_id: + prefix = f"player_select.{panel_id}." + fields = [field for field in fields if str(field.data_key or "").startswith(prefix)] + def target_matches(field: VmixMappingField) -> bool: + if source_key and str(field.vmix_input_key or "") == source_key: + return True + if source_title and str(field.vmix_input_title or "") == source_title: + return True + if source_number and str(field.vmix_input_number or "") == source_number: + return True + return False + fields = [field for field in fields if target_matches(field)] + catalog = self.mapping_data.data_catalog(user, context) + by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")} + result_fields = [] + for field in fields: + item = by_key.get(str(field.data_key or "")) + if item is None: + continue + result_fields.append({ + "name": str(field.vmix_field or ""), + "type": str(field.field_type or "text"), + "data_key": str(field.data_key or ""), + "value": "" if item.get("value") is None else str(item.get("value")), + }) + return { + "device_id": device.device_uuid, + "game_id": str(device.current_match_external_id or ""), + "profile_id": profile.id, + "input": {"key": source_key, "number": source_number, "title": source_title}, + "fields": result_fields, + } + + async def create_prepared_title(self, user: HockeyUser, payload: Any) -> dict[str, Any]: + requested_device = str(getattr(payload, "device_id", "") or "").strip() + session_token = str(getattr(payload, "session_token", "") or "").strip() + with self.database.session() as session: + device = self._prepared_device_for_user(session, user, requested_device) + inventory = self._prepared_inventory_from_row(device) + source = self._prepared_find_input( + inventory, + key=str(getattr(payload, "source_input_key", "") or ""), + number=str(getattr(payload, "source_input_number", "") or ""), + title=str(getattr(payload, "source_input_title", "") or ""), + ) + if source is None: + raise HTTPException(status_code=404, detail="Исходный vMix Input не найден в текущем проекте") + source_key = str(source.get("key") or "") + source_number = str(source.get("number") or "") + source_title = str(source.get("title") or "") + source_ref = source_key or source_number or source_title + before_inputs = [item for item in (inventory.get("inputs") or []) if isinstance(item, dict)] + before_keys = {str(item.get("key") or "") for item in before_inputs if str(item.get("key") or "")} + before_numbers = {str(item.get("number") or "") for item in before_inputs if str(item.get("number") or "")} + numeric_numbers = [int(value) for value in before_numbers if value.isdigit()] + expected_number = str((max(numeric_numbers) if numeric_numbers else len(before_inputs)) + 1) + + create_result = await self.run_vmix_sequence_for_user( + user, + [{"Function": "CreateVirtualInput", "Input": source_ref}], + device_id=device.device_uuid, + session_token=session_token, + timeout=6.0, + ) + target_device_id = str(create_result.get("device_id") or device.device_uuid) + match_id = str(create_result.get("match_id") or "") + + clone: dict[str, Any] | None = None + for _ in range(12): + await asyncio.sleep(0.18) + with self.database.session() as session: + refreshed = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id)) + current_inventory = self._prepared_inventory_from_row(refreshed) + current_inputs = [item for item in (current_inventory.get("inputs") or []) if isinstance(item, dict)] + candidates = [item for item in current_inputs if (str(item.get("key") or "") and str(item.get("key") or "") not in before_keys) or (str(item.get("number") or "") and str(item.get("number") or "") not in before_numbers)] + if candidates: + clone = sorted(candidates, key=lambda item: int(item.get("number") or 0) if str(item.get("number") or "").isdigit() else 0)[-1] + break + clone_key = str(clone.get("key") or "") if clone else "" + clone_number = str(clone.get("number") or expected_number) if clone else expected_number + clone_title = str(clone.get("title") or source_title) if clone else source_title + clone_ref = clone_key or clone_number or clone_title + + raw_values = getattr(payload, "field_values", {}) + raw_values = raw_values if isinstance(raw_values, dict) else {} + source_fields = source.get("fields") if isinstance(source.get("fields"), list) else [] + allowed = {str(field.get("name") or ""): field for field in source_fields if isinstance(field, dict) and str(field.get("name") or "")} + commands: list[dict[str, Any]] = [] + saved_fields: dict[str, dict[str, str]] = {} + for name, field in list(allowed.items())[:240]: + if name not in raw_values: + continue + raw_entry = raw_values.get(name) + if isinstance(raw_entry, dict): + value = "" if raw_entry.get("value") is None else str(raw_entry.get("value")) + requested_type = str(raw_entry.get("type") or "") + else: + value = "" if raw_entry is None else str(raw_entry) + requested_type = "" + field_type = str(field.get("type") or requested_type or "text").lower() + if field_type not in {"text", "image", "source", "color", "colour"}: + field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text" + saved_fields[name] = {"value": value[:8000], "type": field_type} + # Empty text is meaningful (clear inherited content). Empty image/color is + # treated as "leave inherited value" because vMix rejects some empty sources. + if not value and field_type in {"image", "source", "color", "colour"}: + continue + commands.append({ + "Function": _mapping_value_function(field_type), + "Input": clone_ref, + "SelectedName": name, + "Value": value, + }) + set_result = None + if commands: + set_result = await self.run_vmix_sequence_for_user( + user, + commands, + device_id=target_device_id, + session_token=session_token, + timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)), + ) + + title_name = str(getattr(payload, "name", "") or "").strip()[:200] or f"{source_title or 'Title'} · заготовка" + source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual" + source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128] + with self.database.session() as session: + row = VmixPreparedTitle( + wfl_user_id=user.id, + game_external_id=match_id, + device_uuid=target_device_id, + name=title_name, + source_kind=source_kind, + source_ref=source_ref_name, + source_input_key=source_key, + source_input_number=source_number, + source_input_title=source_title, + clone_input_key=clone_key, + clone_input_number=clone_number, + clone_input_title=clone_title, + field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")), + created_by=user.id, + created_at=_utcnow(), + updated_at=_utcnow(), + ) + session.add(row) + session.flush() + result = self._prepared_title_payload(row) + result["ok"] = True + result["create_result"] = create_result + result["set_result"] = set_result or {"ok": True, "applied": 0} + return result + + async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]: + with self.database.session() as session: + row = session.get(VmixPreparedTitle, int(prepared_id)) + if row is None or row.wfl_user_id != user.id: + raise HTTPException(status_code=404, detail="Заготовка не найдена") + requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip() + device = self._prepared_device_for_user(session, user, requested_device) + inventory = self._prepared_inventory_from_row(device) + found = self._prepared_find_input( + inventory, + key=str(row.clone_input_key or ""), + number=str(row.clone_input_number or ""), + title=str(row.clone_input_title or ""), + ) + input_ref = str(found.get("key") or found.get("number") or "") if found is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "") + if not input_ref: + raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix") + return await self.run_vmix_sequence_for_user( + user, + [{"Function": "PreviewInput", "Input": input_ref}], + device_id=device.device_uuid, + session_token=str(getattr(payload, "session_token", "") or ""), + timeout=4.0, + ) + + async def delete_prepared_title(self, prepared_id: int, user: HockeyUser) -> dict[str, Any]: + with self.database.session() as session: + row = session.get(VmixPreparedTitle, int(prepared_id)) + if row is None or row.wfl_user_id != user.id: + raise HTTPException(status_code=404, detail="Заготовка не найдена") + # Deliberately do not RemoveInput in vMix: deleting a web list entry must + # never unexpectedly destroy a studio graphic that may already be used. + session.delete(row) + return {"ok": True, "id": int(prepared_id), "vmix_input_removed": False} + async def latest_vmix_inventory(self) -> dict[str, Any]: """Return the most recent connected vMix project inventory for editor helpers. @@ -3183,6 +3571,23 @@ class MappingSqlPreviewPayload(BaseModel): context: dict[str, Any] = Field(default_factory=dict) +class PreparedTitleCreatePayload(BaseModel): + name: str = Field(default="", max_length=200) + device_id: str = Field(default="", max_length=128) + session_token: str = Field(default="", max_length=128) + source_input_key: str = Field(default="", max_length=128) + source_input_number: str = Field(default="", max_length=32) + source_input_title: str = Field(default="", max_length=300) + source_kind: str = Field(default="manual", max_length=32) + source_ref: str = Field(default="", max_length=128) + field_values: dict[str, Any] = Field(default_factory=dict) + + +class PreparedTitlePreviewPayload(BaseModel): + device_id: str = Field(default="", max_length=128) + session_token: str = Field(default="", max_length=128) + + class AgentHelloPayload(BaseModel): model_config = ConfigDict(extra="ignore") type: str = "hello" @@ -3215,6 +3620,57 @@ def create_hockey_agent_router( ) -> dict[str, Any]: return await hub.vmix_inventory_for_user(user, device_id=device_id) + @router.get("/api/hockey/prepared-titles") + async def prepared_titles_list( + device_id: str = Query("", max_length=128), + game_id: str = Query("", max_length=64), + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.list_prepared_titles(user, device_id=device_id, game_id=game_id) + + @router.get("/api/hockey/prepared-titles/mapping-sources") + async def prepared_titles_mapping_sources( + device_id: str = Query("", max_length=128), + panel_id: str = Query("", max_length=64), + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.prepared_mapping_sources(user, device_id=device_id, panel_id=panel_id) + + @router.get("/api/hockey/prepared-titles/mapping-snapshot") + async def prepared_titles_mapping_snapshot( + device_id: str = Query("", max_length=128), + input_key: str = Query("", max_length=128), + input_number: str = Query("", max_length=32), + input_title: str = Query("", max_length=300), + panel_id: str = Query("", max_length=64), + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.prepared_mapping_snapshot( + user, device_id=device_id, input_key=input_key, input_number=input_number, input_title=input_title, panel_id=panel_id + ) + + @router.post("/api/hockey/prepared-titles") + async def prepared_title_create( + payload: PreparedTitleCreatePayload, + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.create_prepared_title(user, payload) + + @router.post("/api/hockey/prepared-titles/{prepared_id}/preview") + async def prepared_title_preview( + prepared_id: int, + payload: PreparedTitlePreviewPayload, + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.preview_prepared_title(prepared_id, user, payload) + + @router.delete("/api/hockey/prepared-titles/{prepared_id}") + async def prepared_title_delete( + prepared_id: int, + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.delete_prepared_title(prepared_id, user) + @router.post("/api/hockey/agents/devices/{device_id}/pair") async def pair_device( device_id: str, diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index f5677d6..e63b00a 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -76,6 +76,15 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = ( {"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + + {"key": "active_home_penalty_id", "label": "Активное удаление левой команды — ID", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, + {"key": "active_home_penalty_player_id", "label": "Активное удаление левой команды — игрок external ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "active_home_penalty_player_db_id", "label": "Активное удаление левой команды — игрок DB ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "active_home_penalty_team_penalty", "label": "Активное удаление левой команды — командное (1/0)", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + {"key": "active_away_penalty_id", "label": "Активное удаление правой команды — ID", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, + {"key": "active_away_penalty_player_id", "label": "Активное удаление правой команды — игрок external ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "active_away_penalty_player_db_id", "label": "Активное удаление правой команды — игрок DB ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "active_away_penalty_team_penalty", "label": "Активное удаление правой команды — командное (1/0)", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, {"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, @@ -95,9 +104,9 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = ( ) -# These values describe the live state of the match itself, not a private UI -# selection of one account. They must be visible to Mapping regardless of which -# operator/admin account opened the Mapping editor. +# Side-specific penalty selections and active-penalty mirrors are shared inside +# one match. This lets the operator select a HOME/AWAY penalty while an admin +# watches Mapping from another account without getting a private copy. MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({ "selected_home_penalty_id", "selected_home_penalty_player_id", @@ -107,6 +116,14 @@ MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({ "selected_away_penalty_player_id", "selected_away_penalty_player_db_id", "selected_away_penalty_team_penalty", + "active_home_penalty_id", + "active_home_penalty_player_id", + "active_home_penalty_player_db_id", + "active_home_penalty_team_penalty", + "active_away_penalty_id", + "active_away_penalty_player_id", + "active_away_penalty_player_db_id", + "active_away_penalty_team_penalty", }) diff --git a/hockey_data/models.py b/hockey_data/models.py index ecd14d0..37b3130 100644 --- a/hockey_data/models.py +++ b/hockey_data/models.py @@ -981,6 +981,39 @@ class VmixAssignment(Base): ) +class VmixPreparedTitle(Base): + """A match-scoped vMix title clone prepared by an operator. + + The actual graphic lives in vMix. This row only remembers which source was + cloned, where the clone ended up and the field snapshot that was applied. + """ + + __tablename__ = "hockey_vmix_prepared_titles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + wfl_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + game_external_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True) + device_uuid: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False, default="") + source_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="manual") + source_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="") + source_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="") + source_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="") + source_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="") + clone_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="") + clone_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="") + clone_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="") + field_values_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + created_by: Mapped[str] = mapped_column(String(128), nullable=False, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow) + + __table_args__ = ( + Index("ix_hockey_prepared_titles_user_game", "wfl_user_id", "game_external_id", "created_at"), + Index("ix_hockey_prepared_titles_device", "device_uuid", "created_at"), + ) + + class VmixMappingProfile(Base): __tablename__ = "hockey_vmix_mapping_profiles" diff --git a/tests/test_build78_penalty_mapping_context_auto_sync.py b/tests/test_build78_penalty_mapping_context_auto_sync.py index c1d1b43..c0f831d 100644 --- a/tests/test_build78_penalty_mapping_context_auto_sync.py +++ b/tests/test_build78_penalty_mapping_context_auto_sync.py @@ -2,15 +2,36 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") -ADMIN = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8") +MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8") -def test_side_penalty_mapping_context_is_derived_from_active_penalties(): +def test_side_penalty_mapping_context_is_derived_from_active_penalties_without_overwriting_manual_selection(): start = APP.index("async function hockeySyncPenaltySideMappingContext") end = APP.index("function penaltyTargetAssignmentKey", start) snippet = APP[start:end] assert 'sortedPenaltyEntries("home")[0]' in snippet assert 'sortedPenaltyEntries("away")[0]' in snippet + for key in ( + "active_home_penalty_id", + "active_home_penalty_player_id", + "active_home_penalty_player_db_id", + "active_home_penalty_team_penalty", + "active_away_penalty_id", + "active_away_penalty_player_id", + "active_away_penalty_player_db_id", + "active_away_penalty_team_penalty", + ): + assert f"{key}:" in snippet + # BUILD84: selected_* is an explicit operator choice, so the automatic mirror + # must never replace it with the first active penalty. + assert "selected_home_penalty_id:" not in snippet + assert "selected_away_penalty_id:" not in snippet + + +def test_manual_side_penalty_selection_keeps_separate_mapping_identifiers(): + start = APP.index("async function hockeySelectPenaltyForPreview") + end = APP.index("function hockeyPenaltyCard", start) + snippet = APP[start:end] for key in ( "selected_home_penalty_id", "selected_home_penalty_player_id", @@ -22,25 +43,5 @@ def test_side_penalty_mapping_context_is_derived_from_active_penalties(): "selected_away_penalty_team_penalty", ): assert f"{key}:" in snippet - assert 'fetch("/api/hockey/context/batch"' in snippet - - -def test_penalty_context_sync_runs_when_penalty_changes_and_clears_on_finish(): - assert "hockeySyncPenaltySideMappingContext().catch(() => {});" in APP - assert APP.count("hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});") >= 3 - - -def test_saved_penalty_player_keeps_external_and_database_ids(): - start = APP.index("function hockeyCompactPlayer") - end = APP.index("function hockeyCompactInfraction", start) - snippet = APP[start:end] - assert "const externalId" in snippet - assert "const dbId" in snippet - assert "externalId," in snippet - assert "dbId," in snippet - - -def test_mapping_admin_refreshes_live_after_runtime_context_update(): - assert 'window.addEventListener("hockey:mapping-context-updated"' in ADMIN - assert "await loadMappingCatalog()" in ADMIN - assert "renderMapping();" in ADMIN + assert key in MAPPING + assert "const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);" in snippet diff --git a/tests/test_build84_penalty_selection_persistence_prepared_titles.py b/tests/test_build84_penalty_selection_persistence_prepared_titles.py new file mode 100644 index 0000000..154084f --- /dev/null +++ b/tests/test_build84_penalty_selection_persistence_prepared_titles.py @@ -0,0 +1,192 @@ +from pathlib import Path + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.models import VmixPreparedTitle + +ROOT = Path(__file__).resolve().parents[1] +APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8") +BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8") +STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8") + + +def test_home_and_away_penalties_have_independent_manual_selection(): + assert 'selectedPreviewEventIds: { home: "", away: "" }' in APP + assert 'board.selectedPreviewEventIds[side] = event.id' in APP + assert 'card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));' in APP + # The old global selectedEventId must not drive the visual selection class. + penalty_start = APP.index("function hockeyPenaltyCard") + penalty_end = APP.index("function createHockeyDraftFromDrop", penalty_start) + penalty_card = APP[penalty_start:penalty_end] + assert 'card.classList.toggle("is-selected", board.selectedEventId === event.id)' not in penalty_card + assert '.hpd-team-penalty-card.team-home.is-preview-selected' in STYLES + assert '.hpd-team-penalty-card.team-away.is-preview-selected' in STYLES + + +def test_selected_penalty_context_is_manual_and_active_penalty_context_is_separate(): + select_start = APP.index("async function hockeySelectPenaltyForPreview") + select_end = APP.index("function hockeyPenaltyCard", select_start) + select_block = APP[select_start:select_end] + for key in ( + "selected_home_penalty_id", + "selected_home_penalty_player_id", + "selected_home_penalty_player_db_id", + "selected_home_penalty_team_penalty", + "selected_away_penalty_id", + "selected_away_penalty_player_id", + "selected_away_penalty_player_db_id", + "selected_away_penalty_team_penalty", + ): + assert key in select_block + assert key in MAPPING + sync_start = APP.index("async function hockeySyncPenaltySideMappingContext") + sync_end = APP.index("function penaltyTargetAssignmentKey", sync_start) + sync_block = APP[sync_start:sync_end] + assert "active_home_penalty_id:" in sync_block + assert "active_away_penalty_id:" in sync_block + assert "selected_home_penalty_id:" not in sync_block + assert "selected_away_penalty_id:" not in sync_block + + +def test_player_selection_values_survive_reload_using_server_state_and_browser_backup(): + for token in ( + "function hockeyPlayerSelectionStorageKey", + "function hockeyBackupPlayerSelectionValues", + "function hockeyStoredPlayerSelectionValues", + "hockeyBackupPlayerSelectionValues(gameId, payload?.values || {})", + 'if (!control || String(control.game_id || "") !== String(gameId)) return false;', + "const hasServerSelectionKeys", + ): + assert token in APP + assert "localStorage.setItem(hockeyPlayerSelectionStorageKey(id)" in APP + + +def test_player_block_editor_is_compact_single_line(): + assert "/* BUILD84 — compact, single-line player-block editor. */" in STYLES + assert "min-height:48px" in STYLES + assert "grid-template-columns:24px 66px" in STYLES + assert '> Главный' in APP + assert '> Свёрнут' in APP + + +def test_prepared_titles_workspace_exists_and_clones_to_vmix(): + assert '{ id: "prepared_titles", label: "Заготовки" }' in APP + for token in ( + "function renderHockeyPreparedTitlesWorkspace()", + "function hockeyOpenPreparedFromPlayerPanel(panel)", + "data-player-panel-prepared", + "/api/hockey/prepared-titles", + "Сохранить новым Input", + "Подставить из Mapping", + "▶ Preview", + ): + assert token in APP + assert VmixPreparedTitle.__tablename__ == "hockey_vmix_prepared_titles" + assert '{"Function": "CreateVirtualInput", "Input": source_ref}' in BRIDGE + assert '{"Function": "PreviewInput", "Input": input_ref}' in BRIDGE + assert '@router.post("/api/hockey/prepared-titles")' in BRIDGE + assert '@router.get("/api/hockey/prepared-titles/mapping-snapshot")' in BRIDGE + + +def test_prepared_input_resolution_prefers_stable_key_then_number_then_unique_title(): + inventory = { + "inputs": [ + {"key": "stable-a", "number": "7", "title": "TITLE"}, + {"key": "stable-b", "number": "9", "title": "OTHER"}, + ] + } + assert VmixAgentHub._prepared_find_input(inventory, key="stable-b")["number"] == "9" + assert VmixAgentHub._prepared_find_input(inventory, number="7")["key"] == "stable-a" + assert VmixAgentHub._prepared_find_input(inventory, title="OTHER")["key"] == "stable-b" + duplicate = {"inputs": [{"key": "a", "title": "SAME"}, {"key": "b", "title": "SAME"}]} + assert VmixAgentHub._prepared_find_input(duplicate, title="SAME") is None + + +def test_prepared_title_creation_clones_last_input_and_applies_field_snapshot(tmp_path): + import asyncio + import json + import types + from types import SimpleNamespace + from sqlalchemy import select + from hockey_data.auth_bridge import HockeyUser + from hockey_data.models import VmixDevice, VmixPreparedTitle + from tests.support import LocalTestDatabase + + database = LocalTestDatabase(tmp_path / "prepared.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + user = HockeyUser(id="prep-user", login="prep", display_name="Prep") + source_inventory = { + "inputs": [ + { + "key": "source-key", + "number": "12", + "title": "COMPARE", + "type": "GT", + "fields": [ + {"name": "Name.Text", "type": "text", "index": "0"}, + {"name": "Photo.Source", "type": "image", "index": "1"}, + ], + } + ] + } + with database.session() as session: + session.add(VmixDevice( + device_uuid="prep-device", + device_secret_hash="x" * 64, + name="Prep device", + wfl_user_id=user.id, + is_active_for_account=True, + vmix_connected=True, + current_match_external_id="9001", + project_inventory_json=json.dumps(source_inventory), + )) + + calls = [] + + async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0): + calls.append(commands) + if commands and commands[0].get("Function") == "CreateVirtualInput": + cloned = { + "inputs": source_inventory["inputs"] + [ + { + "key": "clone-key", + "number": "13", + "title": "COMPARE", + "type": "GT", + "fields": source_inventory["inputs"][0]["fields"], + } + ] + } + with database.session() as session: + device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "prep-device")) + device.project_inventory_json = json.dumps(cloned) + return {"ok": True, "device_id": device_id or "prep-device", "match_id": "9001"} + + hub.run_vmix_sequence_for_user = types.MethodType(fake_sequence, hub) + payload = SimpleNamespace( + name="Compare ready", + device_id="prep-device", + session_token="session", + source_input_key="source-key", + source_input_number="12", + source_input_title="COMPARE", + source_kind="player_selection", + source_ref="compare_home", + field_values={ + "Name.Text": {"type": "text", "value": "Иванов"}, + "Photo.Source": {"type": "image", "value": r"D:\\Photo\\ivanov.png"}, + }, + ) + + result = asyncio.run(hub.create_prepared_title(user, payload)) + assert result["clone_input"]["number"] == "13" + assert calls[0] == [{"Function": "CreateVirtualInput", "Input": "source-key"}] + assert any(command.get("Function") == "SetText" and command.get("Input") == "clone-key" for command in calls[1]) + assert any(command.get("Function") == "SetImage" and command.get("SelectedName") == "Photo.Source" for command in calls[1]) + with database.session() as session: + row = session.scalar(select(VmixPreparedTitle)) + assert row is not None + assert row.clone_input_key == "clone-key" + assert row.clone_input_number == "13" + assert row.source_ref == "compare_home" diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 86b8ea9..e778911 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -107,6 +107,17 @@ shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] }, shortcutInventoryLoading: false, shortcutEditorScrollTop: 0, + preparedTitles: [], + preparedTitleInventory: { device_id: "", device_name: "", inventory: { inputs: [] } }, + preparedTitlesLoading: false, + preparedTitlesLoadedKey: "", + preparedTitleSearch: "", + preparedTitleSourceKey: "", + preparedTitleFieldValues: {}, + preparedTitleName: "", + preparedTitlePanelId: "", + preparedTitleMappingSources: [], + preparedTitleSnapshotLoading: false, modalLocked: false, timerQuickEditorInterval: null, timers: {}, @@ -2550,6 +2561,13 @@ function startCustomTooltips() { state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch"); state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel"); if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }]; + // BUILD84: "Заготовки" is a built-in runtime workspace rather than a canvas + // component. Keep it near the game tab and recreate it if an older config + // does not contain it yet. + if (!state.config.tabs.some((tab) => tab?.id === "prepared_titles")) { + const mainIndex = Math.max(0, state.config.tabs.findIndex((tab) => tab?.id === "main")); + state.config.tabs.splice(mainIndex + 1, 0, { id: "prepared_titles", label: "Заготовки" }); + } if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; } @@ -4665,14 +4683,14 @@ function startCustomTooltips() { const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home"); const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away"); const values = { - selected_home_penalty_id: home?.penalty_id || "", - selected_home_penalty_player_id: home?.player_id || "", - selected_home_penalty_player_db_id: home?.player_db_id || "", - selected_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "", - selected_away_penalty_id: away?.penalty_id || "", - selected_away_penalty_player_id: away?.player_id || "", - selected_away_penalty_player_db_id: away?.player_db_id || "", - selected_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "", + active_home_penalty_id: home?.penalty_id || "", + active_home_penalty_player_id: home?.player_id || "", + active_home_penalty_player_db_id: home?.player_db_id || "", + active_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "", + active_away_penalty_id: away?.penalty_id || "", + active_away_penalty_player_id: away?.player_id || "", + active_away_penalty_player_db_id: away?.player_db_id || "", + active_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "", }; const signature = JSON.stringify([gameId, values]); if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false; @@ -6715,8 +6733,11 @@ function openTimerQuickEditor(focusActionId = "") { board.penalties = board.penalties.filter((item) => item.id !== event.id); const side = String(event.player?.side || event.side || "").toLowerCase(); const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; - if (board.selectedEventId === event.id) board.selectedEventId = null; - if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = ""; + const clearCommonSelection = board.selectedEventId === event.id; + const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id; + if (clearCommonSelection) board.selectedEventId = null; + if (clearSideSelection) board.selectedPreviewEventIds[side] = ""; + if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(side, { clearCommon: clearCommonSelection }).catch(() => {}); persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); @@ -6735,10 +6756,13 @@ function openTimerQuickEditor(focusActionId = "") { emitHockeyEventUpdated(component, event, "event-time"); } else if (command === "remove") { board.penalties = board.penalties.filter((item) => item.id !== eventId); - if (board.selectedEventId === eventId) board.selectedEventId = null; + const clearCommonSelection = board.selectedEventId === eventId; + if (clearCommonSelection) board.selectedEventId = null; { const removedSide = String(event.player?.side || event.side || "").toLowerCase(); - if (board.selectedPreviewEventIds?.[removedSide] === eventId) board.selectedPreviewEventIds[removedSide] = ""; + const clearSideSelection = board.selectedPreviewEventIds?.[removedSide] === eventId; + if (clearSideSelection) board.selectedPreviewEventIds[removedSide] = ""; + if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(removedSide, { clearCommon: clearCommonSelection }).catch(() => {}); } addHockeyHistory( component, @@ -7342,6 +7366,57 @@ function hockeyPenaltyPlayerIdentifiers(component, event) { return { externalId, dbId }; } +async function hockeyClearSelectedPenaltyMappingContext(side, { clearCommon = false } = {}) { + const gameId = hockeyTimerSelectedGameId(); + const cleanSide = ["home", "away"].includes(String(side || "").toLowerCase()) ? String(side).toLowerCase() : ""; + if (!gameId || !cleanSide) return false; + const values = cleanSide === "home" + ? { + selected_home_penalty_id: "", + selected_home_penalty_player_id: "", + selected_home_penalty_player_db_id: "", + selected_home_penalty_team_penalty: "", + } + : { + selected_away_penalty_id: "", + selected_away_penalty_player_id: "", + selected_away_penalty_player_db_id: "", + selected_away_penalty_team_penalty: "", + }; + if (clearCommon) Object.assign(values, { + selected_penalty_id: "", + selected_penalty_player_id: "", + selected_penalty_player_db_id: "", + selected_penalty_team_penalty: "", + selected_penalty_team_id: "", + selected_penalty_infraction_id: "", + selected_penalty_preset_id: "", + selected_penalty_side: "", + selected_penalty_event_time: "", + selected_penalty_status: "", + selected_penalty_remaining_ms: "", + selected_penalty_duration_ms: "", + selected_event_id: "", + }); + try { + const response = await fetch("/api/hockey/context/batch", { + method: "POST", cache: "no-store", credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + values, + context: { + game_id: gameId, + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + }, + }), + }); + return response.ok; + } catch (_) { + return false; + } +} + function hockeyPenaltyIsPreviewSelected(board, event) { const side = String(event.player?.side || event.side || "").toLowerCase(); return ["home", "away"].includes(side) @@ -7363,6 +7438,7 @@ async function hockeySelectPenaltyForPreview(component, event) { : side === "away" ? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "") : ""; + const playerIds = hockeyPenaltyPlayerIdentifiers(component, event); const detail = { item_id: String(event.external_id || event.id || ""), value: String(event.external_id || event.id || ""), @@ -7461,7 +7537,6 @@ function hockeyPenaltyCard(component, event, runtime) { const side = event.player?.side || event.side || "neutral"; const card = div(`hpd-team-penalty-card team-${side}`); card.dataset.penaltyId = event.id; - card.classList.toggle("is-selected", board.selectedEventId === event.id); card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event)); card.classList.toggle("is-running", event.running); card.classList.toggle("is-finished", event.finished); @@ -7817,8 +7892,8 @@ function openHockeyPrematchButtonsEditor() { - - + +
@@ -8189,6 +8264,7 @@ async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) { if (!Object.keys(values).length) return null; const language = hockeyGameControlLanguage(); const payload = await hockeyUpdateGameControl(gameId, "/control/values", { method: "PUT", body: JSON.stringify({ values, language }) }); + hockeyBackupPlayerSelectionValues(gameId, payload?.values || values); if (refreshMapping) await hockeyRefreshQuickPanelMapping(); return payload; } @@ -8469,6 +8545,43 @@ function hockeyEventCategoryLabels(language) { : {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"}; } +function hockeyPlayerSelectionStorageKey(gameId) { + return `hockey:player-selections:${String(gameId || "").trim()}`; +} + +function hockeyPlayerSelectionValuesOnly(values) { + const result = {}; + Object.entries(values && typeof values === "object" ? values : {}).forEach(([key, value]) => { + if (String(key).startsWith("player_select.")) result[String(key)] = String(value ?? ""); + }); + return result; +} + +function hockeyBackupPlayerSelectionValues(gameId, values) { + const id = String(gameId || "").trim(); + if (!id) return false; + const selected = hockeyPlayerSelectionValuesOnly(values); + if (!Object.keys(selected).length) return false; + try { + localStorage.setItem(hockeyPlayerSelectionStorageKey(id), JSON.stringify({ game_id: id, saved_at: Date.now(), values: selected })); + return true; + } catch (_) { + return false; + } +} + +function hockeyStoredPlayerSelectionValues(gameId) { + const id = String(gameId || "").trim(); + if (!id) return {}; + try { + const payload = JSON.parse(localStorage.getItem(hockeyPlayerSelectionStorageKey(id)) || "{}"); + if (String(payload?.game_id || "") !== id || !payload?.values || typeof payload.values !== "object") return {}; + return hockeyPlayerSelectionValuesOnly(payload.values); + } catch (_) { + return {}; + } +} + function hockeyPlayerSelectionPanels() { return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false); } @@ -8616,8 +8729,17 @@ async function hockeyEnsurePlayerSelectionRuntimeValues() { const gameId = hockeyTimerSelectedGameId(); const panels = hockeyPlayerSelectionPanels(); if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false; + // Do not seed empty values before the saved GameControlState has arrived. + // On a hard refresh that race used to erase an already prepared comparison. + const control = getByPath(state.data, "hockey.game_control"); + if (!control || String(control.game_id || "") !== String(gameId)) return false; const current = hockeyMatchRuntimeValues(); const patch = {}; + const hasServerSelectionKeys = Object.keys(current).some((key) => String(key).startsWith("player_select.")); + if (!hasServerSelectionKeys) { + const backup = hockeyStoredPlayerSelectionValues(gameId); + Object.entries(backup).forEach(([key, value]) => { patch[key] = value; }); + } for (const panel of panels) { const meta = { [`player_select.${panel.id}._label`]: panel.label, @@ -8715,7 +8837,7 @@ function renderHockeyPlayerSelectionWindows() { `; }).join("")} - ${filled ? `` : ""} + ${filled ? `` : ""} `; stack.appendChild(node); node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => { @@ -8773,6 +8895,7 @@ function renderHockeyPlayerSelectionWindows() { event.preventDefault(); event.stopPropagation(); await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1)); })); + node.querySelector("[data-player-panel-prepared]")?.addEventListener("click", () => hockeyOpenPreparedFromPlayerPanel(panel)); node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel)); } setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0); @@ -8789,6 +8912,389 @@ function renderHockeyRuntimeSideWindows() { return Boolean(players || pbp); } + +function hockeyPreparedNumber(value, fallback = 999999) { + const raw = String(value ?? "").trim(); + return /^\d+$/.test(raw) ? Number(raw) : fallback; +} + +function hockeyPreparedInventoryInputs() { + const inventory = state.preparedTitleInventory?.inventory || {}; + return (Array.isArray(inventory.inputs) ? inventory.inputs : []) + .filter((item) => item && typeof item === "object" && Array.isArray(item.fields) && item.fields.length) + .slice() + .sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" })); +} + +function hockeyPreparedSourceIdentity(input) { + if (!input) return ""; + return String(input.key || input.number || input.title || "").trim(); +} + +function hockeyPreparedSelectedInput() { + const identity = String(state.preparedTitleSourceKey || "").trim(); + if (!identity) return null; + return hockeyPreparedInventoryInputs().find((item) => hockeyPreparedSourceIdentity(item) === identity || String(item.key || "") === identity || String(item.number || "") === identity) || null; +} + +function hockeyPreparedFieldType(field) { + const explicit = String(field?.type || "").toLowerCase(); + if (["color", "colour"].includes(explicit) || /\.Color$/i.test(String(field?.name || ""))) return "color"; + if (["image", "source"].includes(explicit) || /\.Source$/i.test(String(field?.name || ""))) return "image"; + return "text"; +} + +function hockeyPreparedFieldValuesForInput(input, { preserve = true } = {}) { + const next = {}; + (Array.isArray(input?.fields) ? input.fields : []).forEach((field) => { + const name = String(field?.name || "").trim(); + if (!name) return; + const type = hockeyPreparedFieldType(field); + const previous = state.preparedTitleFieldValues?.[name]; + next[name] = { + type, + value: preserve && previous && typeof previous === "object" ? String(previous.value ?? "") : "", + touched: Boolean(preserve && previous && typeof previous === "object" && previous.touched), + }; + }); + state.preparedTitleFieldValues = next; + return next; +} + +function hockeyPreparedWorkspaceKey() { + return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|"); +} + +async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) { + const clean = String(panelId || "").trim(); + if (!clean) { + state.preparedTitleMappingSources = []; + if (render && state.activeTab === "prepared_titles") renderRuntime(); + return []; + } + try { + const params = new URLSearchParams(); + const deviceId = currentRuntimeVmixDeviceId(); + if (deviceId) params.set("device_id", deviceId); + params.set("panel_id", clean); + const payload = await api(`/api/hockey/prepared-titles/mapping-sources?${params.toString()}`); + state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : []; + if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) { + const source = state.preparedTitleMappingSources[0]; + const inventoryMatch = hockeyPreparedInventoryInputs().find((item) => + (source.key && String(item.key || "") === String(source.key)) || + (source.number && String(item.number || "") === String(source.number)) || + (source.title && String(item.title || "") === String(source.title)) + ); + if (inventoryMatch) { + state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(inventoryMatch); + hockeyPreparedFieldValuesForInput(inventoryMatch, { preserve: false }); + } + } + if (render && state.activeTab === "prepared_titles") renderRuntime(); + return state.preparedTitleMappingSources; + } catch (error) { + console.error("Prepared title mapping sources error", error); + state.preparedTitleMappingSources = []; + return []; + } +} + +async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true } = {}) { + if (state.preparedTitlesLoading) return false; + const key = hockeyPreparedWorkspaceKey(); + if (!force && state.preparedTitlesLoadedKey === key && state.preparedTitleInventory?.inventory) { + if (state.preparedTitlePanelId && !state.preparedTitleMappingSources.length) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId); + return true; + } + state.preparedTitlesLoading = true; + if (render && state.activeTab === "prepared_titles") renderRuntime(); + try { + const deviceId = currentRuntimeVmixDeviceId(); + const gameId = hockeyTimerSelectedGameId(); + const invParams = new URLSearchParams(); + if (deviceId) invParams.set("device_id", deviceId); + const listParams = new URLSearchParams(); + if (deviceId) listParams.set("device_id", deviceId); + if (gameId) listParams.set("game_id", gameId); + const [inventoryPayload, preparedPayload] = await Promise.all([ + api(`/api/hockey/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`), + api(`/api/hockey/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`), + ]); + state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } }; + state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : []; + state.preparedTitlesLoadedKey = key; + const selected = hockeyPreparedSelectedInput(); + if (state.preparedTitleSourceKey && !selected) { + state.preparedTitleSourceKey = ""; + state.preparedTitleFieldValues = {}; + } else if (selected && !Object.keys(state.preparedTitleFieldValues || {}).length) { + hockeyPreparedFieldValuesForInput(selected, { preserve: false }); + } + if (state.preparedTitlePanelId) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId); + if (state.preparedTitleAutoSnapshotPending && state.preparedTitleSourceKey) { + state.preparedTitleAutoSnapshotPending = false; + setTimeout(() => hockeyApplyPreparedMappingSnapshot().catch(() => {}), 0); + } + } catch (error) { + toast(`Заготовки: ${error.message}`, true); + console.error(error); + } finally { + state.preparedTitlesLoading = false; + if (render && state.activeTab === "prepared_titles") renderRuntime(); + } + return true; +} + +function hockeySelectPreparedSource(input, { clearPanel = false } = {}) { + if (!input) return false; + state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input); + hockeyPreparedFieldValuesForInput(input, { preserve: false }); + if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`; + if (clearPanel) { + state.preparedTitlePanelId = ""; + state.preparedTitleMappingSources = []; + } + renderRuntime(); + return true; +} + +async function hockeyApplyPreparedMappingSnapshot() { + const input = hockeyPreparedSelectedInput(); + if (!input) { + toast("Сначала выберите vMix Input", true); + return false; + } + state.preparedTitleSnapshotLoading = true; + renderRuntime(); + try { + const params = new URLSearchParams(); + const deviceId = currentRuntimeVmixDeviceId(); + if (deviceId) params.set("device_id", deviceId); + if (input.key) params.set("input_key", input.key); + if (input.number) params.set("input_number", input.number); + if (input.title) params.set("input_title", input.title); + if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId); + const payload = await api(`/api/hockey/prepared-titles/mapping-snapshot?${params.toString()}`); + const fields = Array.isArray(payload?.fields) ? payload.fields : []; + if (!fields.length) { + toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true); + return false; + } + const next = { ...(state.preparedTitleFieldValues || {}) }; + fields.forEach((field) => { + const name = String(field?.name || ""); + if (!name || !Object.prototype.hasOwnProperty.call(next, name)) return; + next[name] = { type: hockeyPreparedFieldType(field), value: String(field?.value ?? ""), touched: true }; + }); + state.preparedTitleFieldValues = next; + toast(`Подставлено из Mapping: ${fields.length}`); + return true; + } catch (error) { + toast(`Mapping → заготовка: ${error.message}`, true); + return false; + } finally { + state.preparedTitleSnapshotLoading = false; + renderRuntime(); + } +} + +async function hockeyCreatePreparedTitle() { + const input = hockeyPreparedSelectedInput(); + if (!input) { + toast("Сначала выберите исходный vMix Input", true); + return false; + } + const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`; + const fieldValues = {}; + Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => { + if (!entry?.touched) return; + fieldValues[fieldName] = { + type: String(entry?.type || "text"), + value: String(entry?.value ?? ""), + }; + }); + state.preparedTitlesLoading = true; + renderRuntime(); + try { + const payload = await api("/api/hockey/prepared-titles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + source_input_key: String(input.key || ""), + source_input_number: String(input.number || ""), + source_input_title: String(input.title || ""), + source_kind: state.preparedTitlePanelId ? "player_selection" : "manual", + source_ref: state.preparedTitlePanelId || "", + field_values: fieldValues, + }), + }); + toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`); + state.preparedTitleName = ""; + state.preparedTitlePanelId = ""; + state.preparedTitleMappingSources = []; + state.preparedTitlesLoadedKey = ""; + await hockeyLoadPreparedTitlesWorkspace({ force: true, render: false }); + return true; + } catch (error) { + toast(`Не удалось создать заготовку: ${error.message}`, true); + return false; + } finally { + state.preparedTitlesLoading = false; + renderRuntime(); + } +} + +async function hockeyPreviewPreparedTitle(id) { + try { + await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }), + }); + toast("Заготовка отправлена в Preview"); + return true; + } catch (error) { + toast(`Preview: ${error.message}`, true); + return false; + } +} + +async function hockeyDeletePreparedTitle(id) { + if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false; + try { + await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" }); + state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id)); + renderRuntime(); + return true; + } catch (error) { + toast(`Удаление заготовки: ${error.message}`, true); + return false; + } +} + +function hockeyOpenPreparedFromPlayerPanel(panel) { + if (!panel) return false; + state.preparedTitlePanelId = String(panel.id || ""); + state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`; + state.preparedTitleSourceKey = ""; + state.preparedTitleFieldValues = {}; + state.preparedTitleMappingSources = []; + state.preparedTitleAutoSnapshotPending = true; + state.preparedTitlesLoadedKey = ""; + activateRuntimeTab("prepared_titles", { source: "player-selection" }); + setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0); + return true; +} + +function renderHockeyPreparedTitlesWorkspace() { + const root = document.createElement("section"); + root.className = "hockey-prepared-workspace"; + const inputs = hockeyPreparedInventoryInputs(); + const search = String(state.preparedTitleSearch || "").trim().toLowerCase(); + const filtered = inputs.filter((item) => !search || `${item.number || ""} ${item.title || ""} ${item.type || ""}`.toLowerCase().includes(search)); + const selected = hockeyPreparedSelectedInput(); + const fields = selected && Array.isArray(selected.fields) + ? selected.fields.slice().sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || ""), "ru", { numeric: true, sensitivity: "base" })) + : []; + const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null; + const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : []; + + root.innerHTML = ` +
+
VMIX PRESETSЗаготовки${escapeHtml(state.preparedTitleInventory?.device_name || "Agent не выбран")} · матч ${escapeHtml(hockeyTimerSelectedGameId() || "—")}
+ +
+ ${panel ? `
БЛОК ИГРОКОВ${escapeHtml(panel.label)}Выберите титр, связанный с player_select.${escapeHtml(panel.id)}.*, затем подставьте текущие данные.
` : ""} +
+ +
+ ${selected ? ` +
ИСТОЧНИК#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}${escapeHtml(selected.type || "")} · элементов ${fields.length}
${panel ? `` : ``}
+ +
${fields.map((field) => { + const name = String(field?.name || ""); + const type = hockeyPreparedFieldType(field); + const value = String(state.preparedTitleFieldValues?.[name]?.value ?? ""); + const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff"; + return ``; + }).join("") || `
У Input нет доступных элементов
`}
+
При сохранении vMix создаст виртуальную копию этого Input в конце проекта и заполнит её текущими значениями.
+ ` : `
Выберите vMix InputСправа появятся все его .Text / .Source / .Color элементы для ручной заготовки.
`} +
+ +
`; + + root.querySelector("[data-prepared-refresh]")?.addEventListener("click", () => { + state.preparedTitlesLoadedKey = ""; + hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}); + }); + root.querySelector(".hockey-prepared-search input")?.addEventListener("input", (event) => { + state.preparedTitleSearch = event.target.value || ""; + renderRuntime(); + setTimeout(() => el.runtimeStage.querySelector(".hockey-prepared-search input")?.focus({ preventScroll: true }), 0); + }); + root.querySelectorAll("[data-prepared-input]").forEach((button) => button.addEventListener("click", () => { + const identity = button.dataset.preparedInput || ""; + const input = inputs.find((item) => hockeyPreparedSourceIdentity(item) === identity); + if (input) hockeySelectPreparedSource(input); + })); + root.querySelectorAll("[data-prepared-mapping-source]").forEach((button) => button.addEventListener("click", async () => { + const identity = button.dataset.preparedMappingSource || ""; + const source = mappingSources.find((item) => String(item.key || item.number || item.title || "") === identity); + const input = source ? inputs.find((item) => + (source.key && String(item.key || "") === String(source.key)) || + (source.number && String(item.number || "") === String(source.number)) || + (source.title && String(item.title || "") === String(source.title)) + ) : null; + if (!input) return; + state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input); + hockeyPreparedFieldValuesForInput(input, { preserve: false }); + renderRuntime(); + await hockeyApplyPreparedMappingSnapshot(); + })); + root.querySelector("[data-prepared-clear-context]")?.addEventListener("click", () => { + state.preparedTitlePanelId = ""; + state.preparedTitleMappingSources = []; + renderRuntime(); + }); + root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot()); + root.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; }); + root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => { + const name = inputNode.dataset.preparedField || ""; + if (!name) return; + state.preparedTitleFieldValues[name] = { type: inputNode.dataset.preparedFieldType || "text", value: inputNode.value || "", touched: true }; + if ((inputNode.dataset.preparedFieldType || "") === "color") { + const picker = root.querySelector(`[data-prepared-color="${CSS.escape(name)}"]`); + if (picker && /^#[0-9A-Fa-f]{6}$/.test(inputNode.value || "")) picker.value = inputNode.value; + } + })); + root.querySelectorAll("[data-prepared-color]").forEach((picker) => picker.addEventListener("input", () => { + const name = picker.dataset.preparedColor || ""; + const text = root.querySelector(`[data-prepared-field="${CSS.escape(name)}"]`); + if (text) text.value = picker.value; + state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true }; + })); + root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle()); + root.querySelectorAll("[data-prepared-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview))); + root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete))); + return root; +} + function hockeyEventCategory(item) { const declared = String(item?.category || "").trim().toLowerCase(); // Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a @@ -10588,6 +11094,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { { hockey: { game_control: payload } }, { render } ); + hockeyBackupPlayerSelectionValues(gameId, payload?.values || {}); if (!previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload)) { hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {}); } @@ -11832,6 +12339,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { } function runtimeHasFixedHockeyCanvas() { + if (state.activeTab === "prepared_titles") return true; return state.config.components.some((component) => [ "hockey_penalty_dashboard", @@ -11936,11 +12444,18 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { el.runtimeStage.innerHTML = ""; state.timerNodes = new Map(); state.hockeyPenaltyBoardNodes = new Map(); - state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { - const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); - }); - renderHockeyRuntimeSideWindows(); - renderHockeyQuickCommandDock(); + if (state.activeTab === "prepared_titles") { + el.runtimeStage.appendChild(renderHockeyPreparedTitlesWorkspace()); + if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) { + setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0); + } + } else { + state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { + const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); + }); + renderHockeyRuntimeSideWindows(); + renderHockeyQuickCommandDock(); + } Object.entries(shootoutRosterScroll).forEach(([side, top]) => { const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`); if (list) list.scrollTop = top; diff --git a/ui_builder/static/styles.css b/ui_builder/static/styles.css index d644e94..77f3fdc 100644 --- a/ui_builder/static/styles.css +++ b/ui_builder/static/styles.css @@ -7223,6 +7223,15 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import .hpd-team-penalty-card.is-preview-selected { outline: 2px solid rgba(255, 255, 255, .72); outline-offset: -2px; + box-shadow: 0 0 0 3px rgba(255,255,255,.06); +} +.hpd-team-penalty-card.team-home.is-preview-selected { + outline-color: color-mix(in srgb,var(--hpd-home) 82%,#fff); + box-shadow: 0 0 0 3px color-mix(in srgb,var(--hpd-home) 18%,transparent); +} +.hpd-team-penalty-card.team-away.is-preview-selected { + outline-color: color-mix(in srgb,var(--hpd-away) 82%,#fff); + box-shadow: 0 0 0 3px color-mix(in srgb,var(--hpd-away) 18%,transparent); } .hpd-team-card-identifiers { display: grid; @@ -7433,3 +7442,242 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import user-select:none; } .hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; } + +/* BUILD84 — compact, single-line player-block editor. */ +.player-selection-editor.prematch-editor-section .player-selection-editor-list { gap:4px; } +.player-selection-editor-row { + grid-template-columns:24px 66px minmax(150px,1fr) minmax(135px,.9fr) 52px minmax(135px,.9fr) 84px 82px minmax(150px,1fr) 56px 30px; + gap:5px; + align-items:center; + min-height:48px; + padding:4px 5px; + border-radius:8px; +} +.player-selection-editor-row label { gap:2px; font-size:6.5px; line-height:1.05; } +.player-selection-editor-row input, +.player-selection-editor-row select { height:26px; padding-top:3px; padding-bottom:3px; font-size:9px; } +.player-selection-editor-row .shortcut-inline-check { + align-self:center; + justify-self:start; + gap:4px; + white-space:nowrap; + font-size:7px; +} +.player-selection-editor-row .shortcut-inline-check input { width:16px; height:16px; padding:0; } +.player-selection-editor-description { min-width:120px; } +.player-selection-editor-order { align-items:center; } +.player-selection-editor-order .mini-btn, +.player-selection-editor-row > .icon-btn { width:26px; min-width:26px; height:26px; padding:0; } +@media (max-width:1450px) { + .player-selection-editor-row { + grid-template-columns:24px 64px minmax(130px,1fr) minmax(120px,.9fr) 50px minmax(120px,.9fr) 80px 78px minmax(130px,1fr) 54px 28px; + } +} + +/* BUILD84 — prepared vMix title workspace + player block handoff. */ +.hockey-player-select-footer { + display:flex; + justify-content:space-between; + align-items:center; + gap:6px; + margin-top:7px; +} +.hockey-player-select-prepared { + padding:5px 8px; + color:#071a1d; + border:1px solid #54e5c4; + border-radius:7px; + background:#48dfbd; + font-size:7px; + font-weight:950; + cursor:pointer; +} +.hockey-player-select-prepared:hover { filter:brightness(1.08); } +.hockey-player-select-footer .hockey-player-select-clear { margin-left:auto; } + +.hockey-prepared-workspace { + position:absolute; + inset:0; + display:grid; + grid-template-rows:auto auto minmax(0,1fr); + gap:9px; + padding:14px; + box-sizing:border-box; + color:#dcecff; + background:linear-gradient(180deg,#0a1522 0%,#09131f 100%); + overflow:hidden; +} +.hockey-prepared-head, +.hockey-prepared-context, +.hockey-prepared-source, +.hockey-prepared-savebar, +.hockey-prepared-saved-head { + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; +} +.hockey-prepared-head { + min-height:52px; + padding:0 12px; + border:1px solid #263f56; + border-radius:11px; + background:#0c1b2a; +} +.hockey-prepared-head > div, +.hockey-prepared-context > div, +.hockey-prepared-source > div { min-width:0; display:grid; gap:2px; } +.hockey-prepared-head span, +.hockey-prepared-context span, +.hockey-prepared-source span, +.hockey-prepared-saved-head span { + color:#56e3c1; + font-size:7px; + font-weight:950; + letter-spacing:.08em; +} +.hockey-prepared-head strong { font-size:16px; line-height:1; } +.hockey-prepared-head small, +.hockey-prepared-context small, +.hockey-prepared-source small { color:#7891a8; font-size:8px; } +.hockey-prepared-head button, +.hockey-prepared-context button, +.hockey-prepared-source button, +.hockey-prepared-savebar button, +.hockey-prepared-saved article button { + min-height:29px; + padding:0 10px; + border:1px solid #31516a; + border-radius:7px; + color:#cfe3f3; + background:#10263a; + font-size:8px; + font-weight:900; + cursor:pointer; +} +.hockey-prepared-head button:hover, +.hockey-prepared-source button:hover, +.hockey-prepared-saved article button:hover { border-color:#54dcbf; color:#fff; } +.hockey-prepared-head button:disabled, +.hockey-prepared-source button:disabled, +.hockey-prepared-savebar button:disabled { opacity:.5; cursor:default; } +.hockey-prepared-context { + min-height:42px; + padding:7px 10px; + border:1px solid #31586a; + border-radius:10px; + background:#0d2431; +} +.hockey-prepared-context code { color:#86dfca; } +.hockey-prepared-context > button { width:29px; padding:0; color:#8ba3b8; } +.hockey-prepared-layout { + min-height:0; + display:grid; + grid-template-columns:310px minmax(0,1fr) 310px; + gap:9px; +} +.hockey-prepared-inputs, +.hockey-prepared-editor, +.hockey-prepared-saved { + min-width:0; + min-height:0; + border:1px solid #263f56; + border-radius:11px; + background:#0b1927; + overflow:hidden; +} +.hockey-prepared-inputs { display:grid; grid-template-rows:auto auto minmax(0,1fr); padding:9px; gap:7px; } +.hockey-prepared-search { display:grid; gap:4px; color:#7f98af; font-size:7px; font-weight:900; } +.hockey-prepared-search input, +.hockey-prepared-name input, +.hockey-prepared-field input[type="text"] { + width:100%; + min-width:0; + height:30px; + box-sizing:border-box; + padding:0 9px; + border:1px solid #2b465e; + border-radius:7px; + outline:0; + color:#e5f2ff; + background:#0c2133; + font-size:9px; + font-weight:700; + font-family:inherit; +} +.hockey-prepared-search input:focus, +.hockey-prepared-name input:focus, +.hockey-prepared-field input[type="text"]:focus { border-color:#4ed9bd; box-shadow:0 0 0 1px rgba(78,217,189,.18); } +.hockey-prepared-input-list, +.hockey-prepared-saved-list { min-height:0; overflow:auto; display:grid; align-content:start; gap:4px; padding-right:2px; } +.hockey-prepared-input-list > button, +.hockey-prepared-recommended button { + width:100%; + display:grid; + grid-template-columns:42px minmax(0,1fr) auto; + align-items:center; + gap:7px; + min-height:37px; + padding:5px 7px; + text-align:left; + border:1px solid #243d53; + border-radius:7px; + color:#a8bdd0; + background:#0d2031; + cursor:pointer; +} +.hockey-prepared-input-list > button:hover, +.hockey-prepared-input-list > button.active, +.hockey-prepared-recommended button:hover { border-color:#3e826f; background:#102b3c; } +.hockey-prepared-input-list > button.active { box-shadow:inset 3px 0 0 #4dddbc; } +.hockey-prepared-input-list b, +.hockey-prepared-recommended b { color:#61e0c2; font:900 8px "Roboto Mono",Consolas,monospace; } +.hockey-prepared-input-list span, +.hockey-prepared-recommended span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; font-weight:900; color:#e0effb; } +.hockey-prepared-input-list small, +.hockey-prepared-recommended small { color:#6f879d; font-size:7px; white-space:nowrap; } +.hockey-prepared-recommended { display:grid; gap:4px; padding:6px; border:1px solid #2d554d; border-radius:8px; background:#0d2728; } +.hockey-prepared-recommended > span { color:#70bea9; font-size:7px; font-weight:900; } +.hockey-prepared-editor { display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; gap:8px; padding:9px; } +.hockey-prepared-source { padding:7px 8px; border:1px solid #29475e; border-radius:8px; background:#0d2031; } +.hockey-prepared-source strong { font-size:10px; } +.hockey-prepared-name { display:grid; gap:4px; color:#8099ae; font-size:7px; font-weight:900; } +.hockey-prepared-fields { min-height:0; overflow:auto; display:grid; align-content:start; gap:4px; padding-right:3px; } +.hockey-prepared-field { + min-height:42px; + display:grid; + grid-template-columns:minmax(160px,.8fr) minmax(220px,1.2fr); + align-items:center; + gap:10px; + padding:5px 7px; + border:1px solid #223b51; + border-radius:7px; + background:#0c1d2d; +} +.hockey-prepared-field > span { min-width:0; display:flex; align-items:baseline; gap:7px; } +.hockey-prepared-field > span b { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:850 8px "Roboto Mono",Consolas,monospace; color:#cfe0ee; } +.hockey-prepared-field > span small { color:#52768e; font-size:6px; text-transform:uppercase; } +.hockey-prepared-field > div { min-width:0; display:flex; align-items:center; gap:5px; } +.hockey-prepared-field input[type="color"] { width:32px; min-width:32px; height:30px; padding:2px; border:1px solid #2b465e; border-radius:6px; background:#0c2133; } +.hockey-prepared-savebar { padding-top:7px; border-top:1px solid #21394d; } +.hockey-prepared-savebar small { max-width:520px; color:#70899f; font-size:7px; line-height:1.35; } +.hockey-prepared-savebar button { min-width:155px; color:#071a1d; border-color:#54e5c4; background:#48dfbd; } +.hockey-prepared-saved { display:grid; grid-template-rows:auto minmax(0,1fr); padding:9px; gap:7px; } +.hockey-prepared-saved-head { min-height:28px; padding:0 2px; } +.hockey-prepared-saved-head b { min-width:22px; height:18px; display:grid; place-items:center; border-radius:9px; color:#9ec1d8; background:#1b3850; font:900 8px "Roboto Mono",Consolas,monospace; } +.hockey-prepared-saved article { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px; align-items:center; padding:7px; border:1px solid #243e54; border-radius:8px; background:#0d2031; } +.hockey-prepared-saved article > div:first-child { min-width:0; display:grid; gap:2px; } +.hockey-prepared-saved article span { color:#5c8ca6; font-size:6px; font-weight:950; text-transform:uppercase; } +.hockey-prepared-saved article strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; } +.hockey-prepared-saved article small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#71899e; font-size:7px; } +.hockey-prepared-saved article > div:last-child { display:flex; gap:4px; } +.hockey-prepared-saved article button { min-height:25px; padding:0 7px; font-size:7px; } +.hockey-prepared-saved article button.danger { width:25px; min-width:25px; padding:0; color:#ff7d91; border-color:#593646; } +.hockey-prepared-empty, +.hockey-prepared-editor-empty { display:grid; place-items:center; align-content:center; gap:5px; min-height:90px; padding:16px; text-align:center; color:#637e94; font-size:8px; } +.hockey-prepared-editor-empty { height:100%; } +.hockey-prepared-editor-empty b { color:#b9cfdf; font-size:13px; } +@media (max-width:1100px) { + .hockey-prepared-layout { grid-template-columns:250px minmax(0,1fr) 250px; } + .hockey-prepared-field { grid-template-columns:1fr; gap:4px; } +}