1. поправлено работа с таймерами в vMix
2. поправлено немного Заготовки
This commit is contained in:
@@ -1,22 +1,24 @@
|
||||
# Защита конструктора
|
||||
|
||||
## Формула ежедневного PIN
|
||||
## PIN доступа
|
||||
|
||||
Начиная с BUILD98 используется один фиксированный PIN конструктора:
|
||||
|
||||
```text
|
||||
PIN[i] = (DDMM[i] + MASK[i]) mod 10
|
||||
1993
|
||||
```
|
||||
|
||||
Маска находится в:
|
||||
Его можно переопределить переменной окружения:
|
||||
|
||||
```text
|
||||
settings/editor_security.json
|
||||
EDITOR_FIXED_PIN
|
||||
```
|
||||
|
||||
Или может быть переопределена переменными окружения:
|
||||
Старые поля `daily_mask` и `master_pin` в `settings/editor_security.json` сохраняются только для совместимости со старыми конфигурациями и больше не участвуют в проверке входа.
|
||||
|
||||
Дополнительные настройки сессии:
|
||||
|
||||
```text
|
||||
EDITOR_PIN_MASK
|
||||
EDITOR_MASTER_PIN
|
||||
EDITOR_PIN_TIMEZONE
|
||||
EDITOR_SESSION_MINUTES
|
||||
EDITOR_MAX_ATTEMPTS
|
||||
@@ -27,7 +29,7 @@ EDITOR_LOCK_SECONDS
|
||||
|
||||
1. Оператор открывает `/`.
|
||||
2. Нажимает `Ctrl+Shift+E`.
|
||||
3. Вводит ежедневный PIN.
|
||||
3. Вводит PIN `1993`.
|
||||
4. Редактирует черновик.
|
||||
5. Проверяет через «Предпросмотр».
|
||||
6. Нажимает «Опубликовать».
|
||||
|
||||
4
app.py
4
app.py
@@ -29,7 +29,9 @@ from ui_builder import install_ui_builder
|
||||
from khl_site.khl_data_center import APP as khl_site_app
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
BUILD_VERSION = "2026.08.21.1"
|
||||
BUILD_VERSION = "2026.08.24.2"
|
||||
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
||||
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
||||
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
||||
# compatibility: BUILD_VERSION = "2026.08.20.16"
|
||||
# compatibility: BUILD_VERSION = "2026.08.20.15"
|
||||
|
||||
@@ -2308,6 +2308,96 @@ class VmixAgentHub:
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _prepared_saved_fields(row: VmixPreparedTitle) -> dict[str, dict[str, str]]:
|
||||
try:
|
||||
payload = json.loads(row.field_values_json or "{}")
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
result: dict[str, dict[str, str]] = {}
|
||||
for raw_name, raw_entry in list(payload.items())[:500]:
|
||||
name = str(raw_name or "").strip()[:300]
|
||||
if not name:
|
||||
continue
|
||||
if isinstance(raw_entry, dict):
|
||||
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
|
||||
field_type = str(raw_entry.get("type") or "text").strip().lower()
|
||||
else:
|
||||
value = "" if raw_entry is None else str(raw_entry)
|
||||
field_type = "text"
|
||||
if field_type not in {"text", "image", "source", "color", "colour"}:
|
||||
field_type = "text"
|
||||
result[name] = {"value": value[:8000], "type": field_type}
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _prepared_next_default_name(session: Any, *, user_id: str, device_uuid: str, game_id: str) -> str:
|
||||
query = select(VmixPreparedTitle.name).where(VmixPreparedTitle.wfl_user_id == user_id)
|
||||
if device_uuid:
|
||||
query = query.where(VmixPreparedTitle.device_uuid == device_uuid)
|
||||
if game_id:
|
||||
query = query.where(VmixPreparedTitle.game_external_id == game_id)
|
||||
used: set[int] = set()
|
||||
for raw_name in session.scalars(query):
|
||||
match = re.fullmatch(r"\s*Заготовка\s+(\d+)\s*", str(raw_name or ""), flags=re.IGNORECASE)
|
||||
if match:
|
||||
used.add(int(match.group(1)))
|
||||
number = 1
|
||||
while number in used:
|
||||
number += 1
|
||||
return f"Заготовка {number}"
|
||||
|
||||
@staticmethod
|
||||
def _prepared_field_update_commands(
|
||||
*,
|
||||
input_ref: str,
|
||||
available_fields: list[dict[str, Any]],
|
||||
raw_values: Any,
|
||||
existing_fields: dict[str, dict[str, str]] | None = None,
|
||||
) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]]]:
|
||||
values = raw_values if isinstance(raw_values, dict) else {}
|
||||
saved_fields = dict(existing_fields or {})
|
||||
allowed: dict[str, dict[str, Any]] = {
|
||||
str(field.get("name") or ""): field
|
||||
for field in available_fields
|
||||
if isinstance(field, dict) and str(field.get("name") or "")
|
||||
}
|
||||
# An old Agent inventory may temporarily miss fields of the freshly
|
||||
# cloned Input. Existing saved fields are still safe edit targets.
|
||||
for name, entry in saved_fields.items():
|
||||
allowed.setdefault(name, {"name": name, "type": str(entry.get("type") or "text")})
|
||||
|
||||
commands: list[dict[str, Any]] = []
|
||||
for raw_name, raw_entry in list(values.items())[:240]:
|
||||
name = str(raw_name or "").strip()[:300]
|
||||
field = allowed.get(name)
|
||||
if not name or field is None:
|
||||
continue
|
||||
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 "").strip().lower()
|
||||
else:
|
||||
value = "" if raw_entry is None else str(raw_entry)
|
||||
requested_type = ""
|
||||
existing_type = str(saved_fields.get(name, {}).get("type") or "").strip().lower()
|
||||
field_type = str(field.get("type") or requested_type or existing_type or "text").strip().lower()
|
||||
if field_type not in {"text", "image", "source", "color", "colour"}:
|
||||
field_type = requested_type if requested_type in {"text", "image", "source", "color", "colour"} else "text"
|
||||
saved_fields[name] = {"value": value[:8000], "type": field_type}
|
||||
# Empty text deliberately clears inherited text. Some vMix title
|
||||
# engines reject an empty image/source/color, so leave those alone.
|
||||
if not value and field_type in {"image", "source", "color", "colour"}:
|
||||
continue
|
||||
commands.append({
|
||||
"Function": _mapping_value_function(field_type),
|
||||
"Input": input_ref,
|
||||
"SelectedName": name,
|
||||
"Value": value,
|
||||
})
|
||||
return saved_fields, commands
|
||||
|
||||
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)
|
||||
@@ -2330,7 +2420,12 @@ class VmixAgentHub:
|
||||
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]
|
||||
found_title = str(found.get("title") or "")[:300]
|
||||
# Right after SetInputName the Agent inventory can lag by one
|
||||
# scan. Do not overwrite a freshly stored desired title with
|
||||
# the old inherited source name during that short window.
|
||||
if found_title and (str(row.clone_input_title or "") != str(row.name or "") or found_title == str(row.name or "")):
|
||||
row.clone_input_title = found_title
|
||||
items = [self._prepared_title_payload(row, inventory) for row in rows]
|
||||
return {
|
||||
"device_id": device.device_uuid,
|
||||
@@ -2463,6 +2558,7 @@ class VmixAgentHub:
|
||||
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)
|
||||
current_game_id = str(device.current_match_external_id or "").strip()
|
||||
inventory = self._prepared_inventory_from_row(device)
|
||||
source = self._prepared_find_input(
|
||||
inventory,
|
||||
@@ -2490,9 +2586,22 @@ class VmixAgentHub:
|
||||
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 "")
|
||||
match_id = str(create_result.get("match_id") or current_game_id or "").strip()
|
||||
|
||||
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
|
||||
if requested_name:
|
||||
title_name = requested_name
|
||||
else:
|
||||
with self.database.session() as session:
|
||||
title_name = self._prepared_next_default_name(
|
||||
session,
|
||||
user_id=user.id,
|
||||
device_uuid=target_device_id,
|
||||
game_id=match_id,
|
||||
)
|
||||
|
||||
clone: dict[str, Any] | None = None
|
||||
current_inputs: list[dict[str, Any]] = []
|
||||
for _ in range(12):
|
||||
await asyncio.sleep(0.18)
|
||||
with self.database.session() as session:
|
||||
@@ -2505,50 +2614,43 @@ class VmixAgentHub:
|
||||
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
|
||||
clone_ref = clone_key or clone_number or str(clone.get("title") or source_title if clone else source_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)),
|
||||
)
|
||||
saved_fields, field_commands = self._prepared_field_update_commands(
|
||||
input_ref=clone_ref,
|
||||
available_fields=source_fields,
|
||||
raw_values=raw_values,
|
||||
)
|
||||
|
||||
# BUILD99: the prepared title name is also the actual vMix Input display
|
||||
# name. vMix exposes SetInputName through the Shortcut/API surface.
|
||||
commands: list[dict[str, Any]] = [
|
||||
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
|
||||
*field_commands,
|
||||
]
|
||||
|
||||
# vMix supports selecting categories via API, but does not expose a
|
||||
# supported shortcut to create/label a custom category or assign an
|
||||
# Input to it. Keep the safe fallback requested by the operator: the
|
||||
# prepared title stays at the end of the project. CreateVirtualInput
|
||||
# normally appends already; MoveInput makes that deterministic if the
|
||||
# project reordered while the clone was being discovered.
|
||||
current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()]
|
||||
end_position = max(current_numbers) if current_numbers else 0
|
||||
if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position:
|
||||
commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)})
|
||||
clone_number = str(end_position)
|
||||
|
||||
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:
|
||||
@@ -2564,7 +2666,7 @@ class VmixAgentHub:
|
||||
source_input_title=source_title,
|
||||
clone_input_key=clone_key,
|
||||
clone_input_number=clone_number,
|
||||
clone_input_title=clone_title,
|
||||
clone_input_title=title_name,
|
||||
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
|
||||
created_by=user.id,
|
||||
created_at=_utcnow(),
|
||||
@@ -2575,7 +2677,88 @@ class VmixAgentHub:
|
||||
result = self._prepared_title_payload(row)
|
||||
result["ok"] = True
|
||||
result["create_result"] = create_result
|
||||
result["set_result"] = set_result or {"ok": True, "applied": 0}
|
||||
result["set_result"] = set_result
|
||||
result["placement"] = {
|
||||
"requested_category": "Заготовки",
|
||||
"mode": "end",
|
||||
"category_supported": False,
|
||||
"reason": "vmix_shortcut_api_has_no_input_category_assignment",
|
||||
}
|
||||
return result
|
||||
|
||||
async def update_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
||||
session_token = str(getattr(payload, "session_token", "") or "").strip()
|
||||
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)
|
||||
clone = 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 row.name or ""),
|
||||
)
|
||||
source = self._prepared_find_input(
|
||||
inventory,
|
||||
key=str(row.source_input_key or ""),
|
||||
number=str(row.source_input_number or ""),
|
||||
title=str(row.source_input_title or ""),
|
||||
)
|
||||
clone_ref = str(clone.get("key") or clone.get("number") or "") if clone is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
|
||||
if not clone_ref:
|
||||
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
|
||||
old_fields = self._prepared_saved_fields(row)
|
||||
available_fields = clone.get("fields") if clone is not None and isinstance(clone.get("fields"), list) else []
|
||||
if not available_fields and source is not None and isinstance(source.get("fields"), list):
|
||||
available_fields = source.get("fields")
|
||||
old_name = str(row.name or "").strip()
|
||||
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
|
||||
title_name = requested_name or old_name
|
||||
if not title_name:
|
||||
title_name = self._prepared_next_default_name(
|
||||
session,
|
||||
user_id=user.id,
|
||||
device_uuid=str(row.device_uuid or device.device_uuid or ""),
|
||||
game_id=str(row.game_external_id or device.current_match_external_id or ""),
|
||||
)
|
||||
saved_fields, field_commands = self._prepared_field_update_commands(
|
||||
input_ref=clone_ref,
|
||||
available_fields=available_fields,
|
||||
raw_values=getattr(payload, "field_values", {}),
|
||||
existing_fields=old_fields,
|
||||
)
|
||||
target_device_id = device.device_uuid
|
||||
|
||||
commands: list[dict[str, Any]] = [
|
||||
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
|
||||
*field_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)),
|
||||
)
|
||||
|
||||
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="Заготовка не найдена")
|
||||
row.name = title_name
|
||||
row.clone_input_title = title_name[:300]
|
||||
if clone is not None:
|
||||
row.clone_input_key = str(clone.get("key") or row.clone_input_key or "")[:128]
|
||||
row.clone_input_number = str(clone.get("number") or row.clone_input_number or "")[:32]
|
||||
row.field_values_json = json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":"))
|
||||
row.updated_at = _utcnow()
|
||||
result = self._prepared_title_payload(row)
|
||||
result["ok"] = True
|
||||
result["set_result"] = set_result
|
||||
result["updated_existing_input"] = True
|
||||
return result
|
||||
|
||||
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
|
||||
@@ -3798,6 +3981,13 @@ class PreparedTitleCreatePayload(BaseModel):
|
||||
field_values: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PreparedTitleUpdatePayload(BaseModel):
|
||||
name: str = Field(default="", max_length=200)
|
||||
device_id: str = Field(default="", max_length=128)
|
||||
session_token: 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)
|
||||
@@ -3871,6 +4061,14 @@ def create_hockey_agent_router(
|
||||
) -> dict[str, Any]:
|
||||
return await hub.create_prepared_title(user, payload)
|
||||
|
||||
@router.put("/api/hockey/prepared-titles/{prepared_id}")
|
||||
async def prepared_title_update(
|
||||
prepared_id: int,
|
||||
payload: PreparedTitleUpdatePayload,
|
||||
user: HockeyUser = Depends(auth_dependency),
|
||||
) -> dict[str, Any]:
|
||||
return await hub.update_prepared_title(prepared_id, user, payload)
|
||||
|
||||
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
|
||||
async def prepared_title_preview(
|
||||
prepared_id: int,
|
||||
|
||||
@@ -8,9 +8,10 @@ APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
def test_game_countdown_resume_is_reseeded_before_start():
|
||||
block = APP_JS.split("async function syncActiveVmixGameCountdown", 1)[1].split("function penaltyMirrorKey", 1)[0]
|
||||
assert '["timer_start", "timer_restart", "timer_resume"].includes(eventName)' in block
|
||||
set_pos = block.index('Function: "SetCountdown"')
|
||||
start_pos = block.index('Function: "StartCountdown"')
|
||||
assert set_pos < start_pos
|
||||
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block
|
||||
helper = APP_JS.split("function vmixCountdownSyncCommands", 1)[1].split("function buildShortcutRuntimeContext", 1)[0]
|
||||
start_branch = helper.rsplit("return [", 1)[1]
|
||||
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||
assert 'eventName === "timer_resume"' not in block
|
||||
|
||||
|
||||
@@ -18,8 +19,9 @@ def test_hockey_timer_shortcut_reseeds_game_and_penalty_on_resume():
|
||||
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
|
||||
# Resume must no longer have a StartCountdown-only branch.
|
||||
assert '} else if (action === "resume") {' not in block
|
||||
assert block.count('Function: "SetCountdown"') >= 2
|
||||
assert block.count('Function: "StartCountdown"') >= 2
|
||||
assert block.count('vmixCountdownSyncCommands(') >= 2
|
||||
assert '() => gameTimerState.currentMs' in block
|
||||
assert '() => event.remainingMs' in block
|
||||
|
||||
|
||||
def test_penalty_rebalance_pairs_every_start_with_current_web_value():
|
||||
|
||||
@@ -27,20 +27,21 @@ def test_penalty_vmix_display_uses_only_started_or_paused_active_penalties():
|
||||
|
||||
def test_main_countdown_start_pause_stop_all_hard_sync_web_value():
|
||||
block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey")
|
||||
start = block.split('if (["timer_start", "timer_restart", "timer_resume"].includes(eventName))', 1)[1].split('} else if (eventName === "timer_pause")', 1)[0]
|
||||
assert start.index('Function: "SetCountdown"') < start.index('Function: "StartCountdown"')
|
||||
pause = block.split('eventName === "timer_pause"', 1)[1].split('} else if (["timer_stop", "timer_finished"]', 1)[0]
|
||||
assert pause.index('Function: "PauseCountdown"') < pause.index('Function: "SetCountdown"')
|
||||
stop = block.split('["timer_stop", "timer_finished"].includes(eventName)', 1)[1].split('} else if (["timer_reset"', 1)[0]
|
||||
assert stop.index('Function: "StopCountdown"') < stop.index('Function: "SetCountdown"')
|
||||
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block
|
||||
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause")' in block
|
||||
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block
|
||||
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||
start_branch = helper.rsplit("return [", 1)[1]
|
||||
assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"')
|
||||
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||
|
||||
|
||||
def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
|
||||
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||
assert 'stopCommands.push({ Function: "PauseCountdown"' in block
|
||||
assert 'stopCommands.push({ Function: "SuspendCountdown"' in block
|
||||
assert 'setCommands.push({ Function: "SetCountdown"' in block
|
||||
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
||||
assert "Never StartCountdown for a prepared item" in block
|
||||
assert "prepared/paused penalty" in block
|
||||
|
||||
|
||||
def test_combined_shortcut_changes_web_first_then_synchronizes_vmix():
|
||||
@@ -48,8 +49,9 @@ def test_combined_shortcut_changes_web_first_then_synchronizes_vmix():
|
||||
web_game = block.index("if (step.start_web_game)")
|
||||
vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)")
|
||||
assert web_game < vmix_game
|
||||
assert block.index('Function: "PauseCountdown"') < block.index('Function: "SetCountdown"', block.index('Function: "PauseCountdown"'))
|
||||
assert 'Function: "StartCountdown"' in block
|
||||
assert 'vmixCountdownSyncCommands(' in block
|
||||
assert '() => gameTimerState.currentMs' in block
|
||||
assert '() => event.remainingMs' in block
|
||||
|
||||
|
||||
def test_build95_runtime_version():
|
||||
|
||||
67
tests/test_build98_timer_countdown_sync_fixed_pin.py
Normal file
67
tests/test_build98_timer_countdown_sync_fixed_pin.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ui_builder.auth import EditorAuthManager
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||
APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
AUTH = (ROOT / "ui_builder/auth.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _block(start: str, end: str) -> str:
|
||||
return APP_JS.split(start, 1)[1].split(end, 1)[0]
|
||||
|
||||
|
||||
def test_vmix_queue_resolves_countdown_values_at_actual_send_time():
|
||||
compact = _block("function compactVmixCommand", "function currentRuntimeVmixDeviceId")
|
||||
assert 'typeof rawValue === "function" ? rawValue() : rawValue' in compact
|
||||
sender = _block("async function sendRuntimeVmixSequence", "function splitVmixInputs")
|
||||
assert 'const rawCommands = typeof commands === "function" ? commands() : commands;' in sender
|
||||
assert 'const clean = (rawCommands || []).map(compactVmixCommand)' in sender
|
||||
|
||||
|
||||
def test_countdown_start_is_deterministic_suspend_set_start():
|
||||
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||
start_branch = helper.split('return [', 3)[-1]
|
||||
assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"')
|
||||
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||
assert 'Value: currentValue' in helper
|
||||
|
||||
|
||||
def test_pause_uses_suspend_not_toggle_pausecountdown():
|
||||
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||
pause = helper.split('if (action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0]
|
||||
assert 'Function: "SuspendCountdown"' in pause
|
||||
assert 'Function: "PauseCountdown"' not in pause
|
||||
assert pause.index('Function: "SuspendCountdown"') < pause.index('Function: "SetCountdown"')
|
||||
|
||||
|
||||
def test_penalty_rebalance_hard_syncs_running_countdown_and_pauses_stably():
|
||||
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||
assert 'stopCommands.push({ Function: "SuspendCountdown"' in block
|
||||
assert 'Value: () => vmixCountdownValue(entry.event.remainingMs)' in block
|
||||
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
||||
assert 'Function: "PauseCountdown"' not in block
|
||||
|
||||
|
||||
def test_running_penalty_is_preferred_over_paused_shorter_penalty():
|
||||
sorted_block = _block("function sortedPenaltyEntries", "function penaltyLocalAdvantageSide")
|
||||
assert 'const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));' in sorted_block
|
||||
display = _block("function penaltyDisplayEntriesByTargetSide", "function rememberPenaltyAdvantagePlan")
|
||||
assert 'const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));' in display
|
||||
|
||||
|
||||
def test_fixed_pin_1993_replaces_daily_login(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("EDITOR_FIXED_PIN", raising=False)
|
||||
auth = EditorAuthManager(tmp_path)
|
||||
assert auth.settings["fixed_pin"] == "1993"
|
||||
# The old daily algorithm remains diagnostic-only; for 24.08.2026 its
|
||||
# default result is 6225, but login validation no longer uses it.
|
||||
assert auth.daily_pin(datetime(2026, 8, 24, 12, 0, tzinfo=ZoneInfo("Europe/Moscow"))) == "6225"
|
||||
assert 'valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))' in AUTH
|
||||
|
||||
|
||||
def test_build98_runtime_version():
|
||||
assert 'BUILD_VERSION = "2026.08.24.1"' in APP
|
||||
95
tests/test_build99_prepared_title_editing.py
Normal file
95
tests/test_build99_prepared_title_editing.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import json
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from hockey_data.agent_bridge import VmixAgentHub
|
||||
from hockey_data.auth_bridge import HockeyUser
|
||||
from hockey_data.models import VmixDevice, VmixPreparedTitle
|
||||
from tests.support import LocalTestDatabase
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
|
||||
APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_build99_frontend_and_routes_exist():
|
||||
assert 'preparedTitleEditingId: ""' in APP_JS
|
||||
assert 'data-prepared-edit=' in APP_JS
|
||||
assert 'method: "PUT"' in APP_JS
|
||||
assert '@router.put("/api/hockey/prepared-titles/{prepared_id}")' in BRIDGE
|
||||
assert '"Function": "SetInputName"' in BRIDGE
|
||||
assert 'return f"Заготовка {number}"' in BRIDGE
|
||||
assert 'BUILD_VERSION = "2026.08.24.2"' in APP
|
||||
|
||||
|
||||
def test_blank_name_gets_number_and_existing_clone_is_editable(tmp_path):
|
||||
database = LocalTestDatabase(tmp_path / "prepared99.sqlite3")
|
||||
database.create_all()
|
||||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||
user = HockeyUser(id="prep99", login="prep99", display_name="Prep99")
|
||||
source = {
|
||||
"key": "source-key",
|
||||
"number": "12",
|
||||
"title": "COMPARE",
|
||||
"type": "GT",
|
||||
"fields": [{"name": "Name.Text", "type": "text", "index": "0"}],
|
||||
}
|
||||
inventory = {"inputs": [source]}
|
||||
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(inventory),
|
||||
))
|
||||
|
||||
calls = []
|
||||
|
||||
async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0, **kwargs):
|
||||
calls.append(commands)
|
||||
if commands and commands[0].get("Function") == "CreateVirtualInput":
|
||||
cloned = {"inputs": [source, {**source, "key": "clone-key", "number": "13", "title": "COMPARE"}]}
|
||||
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)
|
||||
created = asyncio.run(hub.create_prepared_title(user, SimpleNamespace(
|
||||
name="",
|
||||
device_id="prep-device",
|
||||
session_token="",
|
||||
source_input_key="source-key",
|
||||
source_input_number="12",
|
||||
source_input_title="COMPARE",
|
||||
source_kind="manual",
|
||||
source_ref="",
|
||||
field_values={"Name.Text": {"type": "text", "value": "Иванов"}},
|
||||
)))
|
||||
assert created["name"] == "Заготовка 1"
|
||||
assert created["clone_input"]["title"] == "Заготовка 1"
|
||||
assert any(c.get("Function") == "SetInputName" and c.get("Value") == "Заготовка 1" for c in calls[1])
|
||||
|
||||
updated = asyncio.run(hub.update_prepared_title(created["id"], user, SimpleNamespace(
|
||||
name="Сравнение вратарей",
|
||||
device_id="prep-device",
|
||||
session_token="",
|
||||
field_values={"Name.Text": {"type": "text", "value": "Петров"}},
|
||||
)))
|
||||
assert updated["name"] == "Сравнение вратарей"
|
||||
assert updated["field_values"]["Name.Text"]["value"] == "Петров"
|
||||
assert any(c.get("Function") == "SetInputName" and c.get("Value") == "Сравнение вратарей" for c in calls[-1])
|
||||
with database.session() as session:
|
||||
row = session.get(VmixPreparedTitle, created["id"])
|
||||
assert row is not None
|
||||
assert row.name == "Сравнение вратарей"
|
||||
assert row.clone_input_title == "Сравнение вратарей"
|
||||
@@ -106,7 +106,7 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() ->
|
||||
|
||||
def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
|
||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||
assert 'Function: "PauseCountdown"' in app_js
|
||||
assert 'Function: "SuspendCountdown"' in app_js
|
||||
assert 'Function: "StopCountdown"' in app_js
|
||||
assert 'hockey_timer_command' in app_js
|
||||
assert 'game_vmix_mode' in app_js
|
||||
@@ -183,8 +183,11 @@ def test_hockey_timer_targets_persist_selected_names_and_finish_actions(tmp_path
|
||||
def test_hockey_countdown_uses_selected_name_for_game_and_penalty_targets() -> None:
|
||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name' in app_js
|
||||
assert 'Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name' in app_js
|
||||
assert 'function vmixCountdownSyncCommands(input, selectedName' in app_js
|
||||
assert 'SelectedName: String(selectedName || "").trim()' in app_js
|
||||
assert 'step.game_vmix_selected_name, () => gameTimerState.currentMs' in app_js
|
||||
assert 'target.input, target.selected_name, () => event.remainingMs' in app_js
|
||||
assert 'Input: target.input, SelectedName: target.selected_name' in app_js
|
||||
assert 'vmixTextSelectedNameOptions' in app_js
|
||||
assert 'data-penalty-target-field="selected_name"' in app_js
|
||||
assert 'data-finish-action-add' in app_js
|
||||
@@ -249,7 +252,7 @@ def test_build90_native_countdown_avoids_per_second_transport() -> None:
|
||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||
assert 'game_vmix_mode !== "countdown"' in app_js
|
||||
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
|
||||
assert 'Function: "PauseCountdown"' in app_js
|
||||
assert 'Function: "SuspendCountdown"' in app_js
|
||||
assert 'Function: "StopCountdown"' in app_js
|
||||
assert 'signal: controller.signal' in app_js
|
||||
assert 'vmixCommandQueue: Promise.resolve()' in app_js
|
||||
|
||||
@@ -18,6 +18,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
DEFAULT_SECURITY: dict[str, Any] = {
|
||||
# BUILD98: editor access is a fixed local PIN. The older daily PIN and
|
||||
# master PIN fields are retained only so existing config files still parse.
|
||||
"fixed_pin": "1993",
|
||||
"daily_mask": "4827",
|
||||
"master_pin": "638194",
|
||||
"timezone": "Europe/Moscow",
|
||||
@@ -43,11 +46,9 @@ class FailureState:
|
||||
class EditorAuthManager:
|
||||
"""Local PIN guard for the visual editor.
|
||||
|
||||
Daily PIN:
|
||||
digits(DDMM) + digits(mask), each result modulo 10.
|
||||
|
||||
Example:
|
||||
1607 + 4827 = 5424
|
||||
BUILD98 uses one fixed PIN (1993 by default). The old daily-PIN helpers are
|
||||
kept for backward-compatible diagnostics only and are no longer accepted
|
||||
by the login endpoint.
|
||||
"""
|
||||
|
||||
def __init__(self, settings_dir: Path) -> None:
|
||||
@@ -76,6 +77,7 @@ class EditorAuthManager:
|
||||
result.update(raw)
|
||||
|
||||
overrides = {
|
||||
"fixed_pin": os.getenv("EDITOR_FIXED_PIN"),
|
||||
"daily_mask": os.getenv("EDITOR_PIN_MASK"),
|
||||
"master_pin": os.getenv("EDITOR_MASTER_PIN"),
|
||||
"timezone": os.getenv("EDITOR_PIN_TIMEZONE"),
|
||||
@@ -93,6 +95,9 @@ class EditorAuthManager:
|
||||
"1", "true", "yes", "on"
|
||||
}
|
||||
|
||||
fixed = "".join(character for character in str(result.get("fixed_pin", "1993")) if character.isdigit())
|
||||
result["fixed_pin"] = fixed if 4 <= len(fixed) <= 12 else "1993"
|
||||
|
||||
mask = "".join(character for character in str(result["daily_mask"]) if character.isdigit())
|
||||
result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827"
|
||||
|
||||
@@ -217,10 +222,7 @@ class EditorAuthManager:
|
||||
},
|
||||
)
|
||||
|
||||
valid = (
|
||||
hmac.compare_digest(supplied, self.daily_pin())
|
||||
or hmac.compare_digest(supplied, str(self.settings["master_pin"]))
|
||||
)
|
||||
valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))
|
||||
if not valid:
|
||||
failure.attempts += 1
|
||||
remaining = max(
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
preparedTitleSourceKey: "",
|
||||
preparedTitleFieldValues: {},
|
||||
preparedTitleName: "",
|
||||
preparedTitleEditingId: "",
|
||||
preparedTitlePanelId: "",
|
||||
preparedTitleMappingSources: [],
|
||||
preparedTitleSnapshotLoading: false,
|
||||
@@ -4134,6 +4135,36 @@ function startCustomTooltips() {
|
||||
return `${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`;
|
||||
}
|
||||
|
||||
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
|
||||
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
||||
const currentValue = () => {
|
||||
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
|
||||
return vmixCountdownValue(milliseconds);
|
||||
};
|
||||
if (!target.Input || !target.SelectedName) return [];
|
||||
if (action === "stop") {
|
||||
return [
|
||||
{ Function: "StopCountdown", ...target },
|
||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||
];
|
||||
}
|
||||
if (action === "pause") {
|
||||
// vMix PauseCountdown is a toggle (pause/resume). SuspendCountdown is the
|
||||
// deterministic pause-only command, so it cannot accidentally resume a timer.
|
||||
return [
|
||||
{ Function: "SuspendCountdown", ...target },
|
||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||
];
|
||||
}
|
||||
return [
|
||||
// Hard-sync every launch: freeze any stale title countdown first, sample
|
||||
// Runtime at actual send time, then start from exactly that value.
|
||||
{ Function: "SuspendCountdown", ...target },
|
||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||
{ Function: "StartCountdown", ...target },
|
||||
];
|
||||
}
|
||||
|
||||
function buildShortcutRuntimeContext(sequence = null) {
|
||||
const timers = {};
|
||||
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
|
||||
@@ -4213,8 +4244,13 @@ function startCustomTooltips() {
|
||||
}
|
||||
|
||||
function compactVmixCommand(command) {
|
||||
// BUILD98: countdown values may be supplied as functions. They are resolved
|
||||
// only when the command actually reaches the front of the browser vMix queue,
|
||||
// so a delayed Agent/ACK cannot make vMix start several seconds behind Runtime.
|
||||
const source = typeof command === "function" ? command() : command;
|
||||
const result = {};
|
||||
Object.entries(command || {}).forEach(([key, value]) => {
|
||||
Object.entries(source || {}).forEach(([key, rawValue]) => {
|
||||
const value = typeof rawValue === "function" ? rawValue() : rawValue;
|
||||
if (value === null || value === undefined) return;
|
||||
if (key !== "Value" && String(value) === "") return;
|
||||
result[key] = value;
|
||||
@@ -4522,10 +4558,13 @@ function startCustomTooltips() {
|
||||
}
|
||||
|
||||
async function sendRuntimeVmixSequence(commands, execution = null) {
|
||||
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
|
||||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||||
|
||||
// Do not resolve dynamic command values here. A sequence can sit behind a
|
||||
// previous Agent request for a few seconds; countdowns must be sampled at
|
||||
// the instant this queued request is really sent.
|
||||
const run = async () => {
|
||||
const rawCommands = typeof commands === "function" ? commands() : commands;
|
||||
const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function);
|
||||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||||
state.vmixCommandQueueDepth += 1;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
|
||||
@@ -4621,26 +4660,17 @@ function startCustomTooltips() {
|
||||
const input = String(step.game_vmix_input || "").trim();
|
||||
const selectedName = String(step.game_vmix_selected_name || "").trim();
|
||||
if (!input || !selectedName) continue;
|
||||
const target = { Input: input, SelectedName: selectedName };
|
||||
const valueProvider = () => timerState.currentMs;
|
||||
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
|
||||
// BUILD95: every launch/resume is a hard Runtime -> vMix sync.
|
||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||
commands.push({ Function: "StartCountdown", ...target });
|
||||
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start"));
|
||||
} else if (eventName === "timer_pause") {
|
||||
// Freeze vMix first, then overwrite it with the exact web value.
|
||||
commands.push({ Function: "PauseCountdown", ...target });
|
||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause"));
|
||||
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
|
||||
commands.push({ Function: "StopCountdown", ...target });
|
||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop"));
|
||||
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
|
||||
if (timerState.running) {
|
||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||
commands.push({ Function: "StartCountdown", ...target });
|
||||
} else {
|
||||
commands.push({ Function: "PauseCountdown", ...target });
|
||||
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
|
||||
}
|
||||
commands.push(...vmixCountdownSyncCommands(
|
||||
input, selectedName, valueProvider, timerState.running ? "start" : "pause"
|
||||
));
|
||||
}
|
||||
}
|
||||
if (!commands.length) return false;
|
||||
@@ -4648,7 +4678,6 @@ function startCustomTooltips() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function penaltyMirrorKey(component, event) {
|
||||
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
|
||||
}
|
||||
@@ -4713,8 +4742,15 @@ function startCustomTooltips() {
|
||||
function sortedPenaltyEntries(side = "") {
|
||||
return activeHockeyPenaltyEntries()
|
||||
.filter((item) => !side || item.side === side)
|
||||
.sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
||||
.sort((a, b) => {
|
||||
// BUILD98: a paused penalty cannot be the next real strength transition
|
||||
// while another active penalty is actually running. Prefer running
|
||||
// clocks; only fall back to paused ones when every active clock is paused.
|
||||
const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));
|
||||
if (runningOrder) return runningOrder;
|
||||
return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0);
|
||||
});
|
||||
}
|
||||
|
||||
// BUILD89: the scorebug has only ONE penalty/power-play plate at a time.
|
||||
@@ -4758,8 +4794,12 @@ function startCustomTooltips() {
|
||||
function penaltyDisplayEntriesByTargetSide(step) {
|
||||
const home = sortedPenaltyEntries("home");
|
||||
const away = sortedPenaltyEntries("away");
|
||||
const all = [...home, ...away].sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
|
||||
const all = [...home, ...away].sort((a, b) => {
|
||||
const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));
|
||||
if (runningOrder) return runningOrder;
|
||||
return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|
||||
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0);
|
||||
});
|
||||
if (!all.length) {
|
||||
return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null };
|
||||
}
|
||||
@@ -4946,20 +4986,21 @@ function startCustomTooltips() {
|
||||
});
|
||||
const startingCountdown = Boolean(entry.event.running)
|
||||
&& (force || assignmentChanged || previous?.running !== true);
|
||||
// BUILD93 invariant: whenever StartCountdown is emitted, SetCountdown with
|
||||
// the current web value is emitted immediately before it.
|
||||
if (entry.event.running) {
|
||||
if (force || assignmentChanged || startingCountdown) {
|
||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
|
||||
// BUILD98: deterministic hard sync. Suspend is pause-only in vMix;
|
||||
// PauseCountdown is a toggle and could accidentally resume a stale timer.
|
||||
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
||||
}
|
||||
if (startingCountdown) {
|
||||
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||
}
|
||||
} else if (force || assignmentChanged || previous?.running !== false) {
|
||||
// BUILD95: on every pause/stop, freeze first and then seed the
|
||||
// exact Runtime value. Never StartCountdown for a prepared item.
|
||||
stopCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
|
||||
// Freeze the title and seed the exact Runtime value, but never run a
|
||||
// prepared/paused penalty until the web event itself is running.
|
||||
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
||||
}
|
||||
} else {
|
||||
assignedMirrorKeys.add(eventKey);
|
||||
@@ -5147,12 +5188,13 @@ function startCustomTooltips() {
|
||||
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
|
||||
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
|
||||
} else if (pausing) {
|
||||
commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
||||
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
||||
commands.push(...vmixCountdownSyncCommands(
|
||||
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
|
||||
));
|
||||
} else {
|
||||
// BUILD95: every start/resume is SetCountdown -> StartCountdown.
|
||||
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
|
||||
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
|
||||
commands.push(...vmixCountdownSyncCommands(
|
||||
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5176,17 +5218,14 @@ function startCustomTooltips() {
|
||||
});
|
||||
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
|
||||
} else {
|
||||
const eventRunning = Boolean(event.running);
|
||||
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
|
||||
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: !pausing,
|
||||
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: eventRunning,
|
||||
});
|
||||
if (pausing) {
|
||||
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
||||
} else {
|
||||
// BUILD95: every penalty launch/resume is re-seeded from the web timer first.
|
||||
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
|
||||
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||
}
|
||||
commands.push(...vmixCountdownSyncCommands(
|
||||
target.input, target.selected_name, () => event.remainingMs,
|
||||
(pausing || !eventRunning) ? "pause" : "start"
|
||||
));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9337,9 +9376,10 @@ async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true
|
||||
|
||||
function hockeySelectPreparedSource(input, { clearPanel = false } = {}) {
|
||||
if (!input) return false;
|
||||
state.preparedTitleEditingId = "";
|
||||
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
|
||||
hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
||||
if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`;
|
||||
state.preparedTitleName = "";
|
||||
if (clearPanel) {
|
||||
state.preparedTitlePanelId = "";
|
||||
state.preparedTitleMappingSources = [];
|
||||
@@ -9394,7 +9434,7 @@ async function hockeyCreatePreparedTitle() {
|
||||
toast("Сначала выберите исходный vMix Input", true);
|
||||
return false;
|
||||
}
|
||||
const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`;
|
||||
const name = String(state.preparedTitleName || "").trim();
|
||||
const fieldValues = {};
|
||||
Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => {
|
||||
if (!entry?.touched) return;
|
||||
@@ -9423,6 +9463,7 @@ async function hockeyCreatePreparedTitle() {
|
||||
});
|
||||
toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
|
||||
state.preparedTitleName = "";
|
||||
state.preparedTitleEditingId = "";
|
||||
state.preparedTitlePanelId = "";
|
||||
state.preparedTitleMappingSources = [];
|
||||
state.preparedTitlesLoadedKey = "";
|
||||
@@ -9437,6 +9478,90 @@ async function hockeyCreatePreparedTitle() {
|
||||
}
|
||||
}
|
||||
|
||||
function hockeyPreparedInventoryMatch(ref = {}) {
|
||||
const inputs = hockeyPreparedInventoryInputs();
|
||||
return inputs.find((item) =>
|
||||
(ref?.key && String(item.key || "") === String(ref.key)) ||
|
||||
(ref?.number && String(item.number || "") === String(ref.number)) ||
|
||||
(ref?.title && String(item.title || "") === String(ref.title))
|
||||
) || null;
|
||||
}
|
||||
|
||||
function hockeyBeginEditPreparedTitle(id) {
|
||||
const item = state.preparedTitles.find((entry) => String(entry?.id) === String(id));
|
||||
if (!item) return false;
|
||||
const input = hockeyPreparedInventoryMatch(item.clone_input) || hockeyPreparedInventoryMatch(item.source_input);
|
||||
state.preparedTitleEditingId = String(item.id || "");
|
||||
state.preparedTitleName = String(item.name || "");
|
||||
state.preparedTitlePanelId = "";
|
||||
state.preparedTitleMappingSources = [];
|
||||
state.preparedTitleSourceKey = input ? hockeyPreparedSourceIdentity(input) : "";
|
||||
state.preparedTitleFieldValues = {};
|
||||
if (input) hockeyPreparedFieldValuesForInput(input, { preserve: false });
|
||||
Object.entries(item.field_values || {}).forEach(([name, raw]) => {
|
||||
if (!name) return;
|
||||
const entry = raw && typeof raw === "object" ? raw : { value: raw, type: "text" };
|
||||
const currentType = state.preparedTitleFieldValues?.[name]?.type || String(entry.type || "text");
|
||||
state.preparedTitleFieldValues[name] = {
|
||||
type: currentType,
|
||||
value: String(entry.value ?? ""),
|
||||
touched: true,
|
||||
};
|
||||
});
|
||||
renderRuntime();
|
||||
return true;
|
||||
}
|
||||
|
||||
function hockeyCancelPreparedEdit() {
|
||||
state.preparedTitleEditingId = "";
|
||||
state.preparedTitleName = "";
|
||||
state.preparedTitleSourceKey = "";
|
||||
state.preparedTitleFieldValues = {};
|
||||
renderRuntime();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function hockeyUpdatePreparedTitle() {
|
||||
const id = String(state.preparedTitleEditingId || "").trim();
|
||||
if (!id) return false;
|
||||
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 hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: String(state.preparedTitleName || "").trim(),
|
||||
device_id: currentRuntimeVmixDeviceId(),
|
||||
session_token: currentRuntimeHockeySessionToken(),
|
||||
field_values: fieldValues,
|
||||
}),
|
||||
});
|
||||
toast(`Заготовка обновлена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
|
||||
state.preparedTitleEditingId = "";
|
||||
state.preparedTitleName = "";
|
||||
state.preparedTitleSourceKey = "";
|
||||
state.preparedTitleFieldValues = {};
|
||||
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 hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, {
|
||||
@@ -9457,6 +9582,7 @@ async function hockeyDeletePreparedTitle(id) {
|
||||
try {
|
||||
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
|
||||
if (String(state.preparedTitleEditingId || "") === String(id)) hockeyCancelPreparedEdit();
|
||||
renderRuntime();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -9467,8 +9593,9 @@ async function hockeyDeletePreparedTitle(id) {
|
||||
|
||||
function hockeyOpenPreparedFromPlayerPanel(panel) {
|
||||
if (!panel) return false;
|
||||
state.preparedTitleEditingId = "";
|
||||
state.preparedTitlePanelId = String(panel.id || "");
|
||||
state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`;
|
||||
state.preparedTitleName = "";
|
||||
state.preparedTitleSourceKey = "";
|
||||
state.preparedTitleFieldValues = {};
|
||||
state.preparedTitleMappingSources = [];
|
||||
@@ -9491,6 +9618,8 @@ function renderHockeyPreparedTitlesWorkspace() {
|
||||
: [];
|
||||
const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null;
|
||||
const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : [];
|
||||
const editingId = String(state.preparedTitleEditingId || "").trim();
|
||||
const editingItem = editingId ? state.preparedTitles.find((item) => String(item?.id) === editingId) : null;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="hockey-prepared-head">
|
||||
@@ -9510,8 +9639,8 @@ function renderHockeyPreparedTitlesWorkspace() {
|
||||
</aside>
|
||||
<main class="hockey-prepared-editor">
|
||||
${selected ? `
|
||||
<div class="hockey-prepared-source"><div><span>ИСТОЧНИК</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${escapeHtml(selected.type || "")} · элементов ${fields.length}</small></div>${panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div>
|
||||
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Например: Сравнение вратарей · студия"></label>
|
||||
<div class="hockey-prepared-source"><div><span>${editingItem ? "РЕДАКТИРОВАНИЕ" : "ИСТОЧНИК"}</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${editingItem ? `Заготовка #${escapeHtml(editingItem.id)} · изменяется существующий Input` : `${escapeHtml(selected.type || "")} · элементов ${fields.length}`}</small></div>${editingItem ? `<button type="button" data-prepared-cancel-edit>Отмена</button>` : panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div>
|
||||
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Если пусто — Заготовка N"></label>
|
||||
<div class="hockey-prepared-fields">${fields.map((field) => {
|
||||
const name = String(field?.name || "");
|
||||
const type = hockeyPreparedFieldType(field);
|
||||
@@ -9519,12 +9648,12 @@ function renderHockeyPreparedTitlesWorkspace() {
|
||||
const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff";
|
||||
return `<label class="hockey-prepared-field" data-field-type="${escapeHtml(type)}"><span><b>${escapeHtml(name)}</b><small>${escapeHtml(type)}</small></span><div>${type === "color" ? `<input type="color" data-prepared-color="${escapeHtml(name)}" value="${escapeHtml(color)}">` : ""}<input type="text" data-prepared-field="${escapeHtml(name)}" data-prepared-field-type="${escapeHtml(type)}" value="${escapeHtml(value)}" placeholder="${type === "image" ? "путь к изображению" : type === "color" ? "#RRGGBB" : "текст"}"></div></label>`;
|
||||
}).join("") || `<div class="hockey-prepared-empty">У Input нет доступных элементов</div>`}</div>
|
||||
<div class="hockey-prepared-savebar"><small>При сохранении vMix создаст виртуальную копию этого Input в конце проекта и заполнит её текущими значениями.</small><button type="button" data-prepared-save ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю…" : "+ Сохранить новым Input"}</button></div>
|
||||
<div class="hockey-prepared-savebar"><small>${editingItem ? "Изменения применятся к уже созданному vMix Input: название и отредактированные поля обновятся без создания новой копии." : "Создаётся виртуальная копия, Input получает имя заготовки. vMix API не даёт назначить/создать категорию программно, поэтому копия остаётся в конце проекта."}</small><button type="button" ${editingItem ? "data-prepared-update" : "data-prepared-save"} ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю…" : editingItem ? "✓ Сохранить изменения" : "+ Сохранить новым Input"}</button></div>
|
||||
` : `<div class="hockey-prepared-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`}
|
||||
</main>
|
||||
<aside class="hockey-prepared-saved">
|
||||
<div class="hockey-prepared-saved-head"><span>СОХРАНЁННЫЕ</span><b>${state.preparedTitles.length}</b></div>
|
||||
<div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || "Заготовка")}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · из #${escapeHtml(item.source_input?.number || "—")} ${escapeHtml(item.source_input?.title || "")}</small></div><div><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div>
|
||||
<div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article class="${String(item.id) === editingId ? "editing" : ""}"><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || `Заготовка ${item.id || ""}`)}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · ${escapeHtml(item.clone_input?.title || item.name || "")} · из #${escapeHtml(item.source_input?.number || "—")}</small></div><div><button type="button" data-prepared-edit="${escapeHtml(item.id)}" title="Изменить название и данные">✎</button><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div>
|
||||
</aside>
|
||||
</div>`;
|
||||
|
||||
@@ -9562,6 +9691,7 @@ function renderHockeyPreparedTitlesWorkspace() {
|
||||
renderRuntime();
|
||||
});
|
||||
root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot());
|
||||
root.querySelector("[data-prepared-cancel-edit]")?.addEventListener("click", () => hockeyCancelPreparedEdit());
|
||||
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 || "";
|
||||
@@ -9579,6 +9709,8 @@ function renderHockeyPreparedTitlesWorkspace() {
|
||||
state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true };
|
||||
}));
|
||||
root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle());
|
||||
root.querySelector("[data-prepared-update]")?.addEventListener("click", () => hockeyUpdatePreparedTitle());
|
||||
root.querySelectorAll("[data-prepared-edit]").forEach((button) => button.addEventListener("click", () => hockeyBeginEditPreparedTitle(button.dataset.preparedEdit)));
|
||||
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;
|
||||
@@ -12935,7 +13067,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
<div class="editor-pin-icon">⌘</div>
|
||||
<div class="editor-pin-copy">
|
||||
<strong>Защищённый режим конструктора</strong>
|
||||
<p>Введите ежедневный четырёхзначный PIN или аварийный мастер-PIN.</p>
|
||||
<p>Введите PIN доступа к конструктору.</p>
|
||||
</div>
|
||||
<label class="editor-pin-field">
|
||||
<span>PIN-код</span>
|
||||
|
||||
@@ -7666,6 +7666,7 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
|
||||
.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.editing { border-color:#4dddbc; box-shadow:inset 3px 0 0 #4dddbc; background:#102b34; }
|
||||
.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; }
|
||||
|
||||
Reference in New Issue
Block a user