выделение удалений, и заготовки

This commit is contained in:
2026-08-20 13:33:42 +03:00
parent 3cea13c103
commit 381e630c77
7 changed files with 1511 additions and 49 deletions

View File

@@ -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() {
<label>ID для Mapping<input type="text" maxlength="48" data-player-panel-field="id" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.id)}"></label>
<label>Слотов<input type="number" min="1" max="6" data-player-panel-field="slots" data-player-panel-id="${escapeHtml(panel.id)}" value="${panel.slots}"></label>
<label>Правило<select data-player-panel-field="rule" data-player-panel-id="${escapeHtml(panel.id)}">${triggerSelectOptions([["any","Любые игроки"],["home","Только левая команда"],["away","Только правая команда"],["different_teams","Разные команды"]], panel.rule)}</select></label>
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный выбранный игрок</label>
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> По умолчанию свёрнут</label>
<label class="shortcut-inline-check" title="Использовать первый слот как общий selected_player"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный</label>
<label class="shortcut-inline-check" title="Сворачивать блок при первом открытии"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> Свёрнут</label>
<label class="player-selection-editor-description">Описание<input type="text" maxlength="300" data-player-panel-field="description" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.description)}"></label>
<div class="player-selection-editor-order"><button type="button" class="mini-btn" data-player-panel-up="${escapeHtml(panel.id)}" ${index <= 0 ? "disabled" : ""}>↑</button><button type="button" class="mini-btn" data-player-panel-down="${escapeHtml(panel.id)}" ${index >= draftPlayerPanels.length - 1 ? "disabled" : ""}>↓</button></div>
<button type="button" class="icon-btn danger" data-player-panel-delete="${escapeHtml(panel.id)}" title="Удалить блок">×</button>
@@ -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() {
</div>`;
}).join("")}
</div>
${filled ? `<button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button>` : ""}
${filled ? `<div class="hockey-player-select-footer"><button type="button" class="hockey-player-select-prepared" data-player-panel-prepared="${escapeHtml(panel.id)}"> В заготовки</button><button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button></div>` : ""}
</div>`;
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 = `
<div class="hockey-prepared-head">
<div><span>VMIX PRESETS</span><strong>Заготовки</strong><small>${escapeHtml(state.preparedTitleInventory?.device_name || "Agent не выбран")} · матч ${escapeHtml(hockeyTimerSelectedGameId() || "—")}</small></div>
<button type="button" data-prepared-refresh ${state.preparedTitlesLoading ? "disabled" : ""}>↻ Обновить vMix</button>
</div>
${panel ? `<div class="hockey-prepared-context"><div><span>БЛОК ИГРОКОВ</span><strong>${escapeHtml(panel.label)}</strong><small>Выберите титр, связанный с <code>player_select.${escapeHtml(panel.id)}.*</code>, затем подставьте текущие данные.</small></div><button type="button" data-prepared-clear-context>×</button></div>` : ""}
<div class="hockey-prepared-layout">
<aside class="hockey-prepared-inputs">
<label class="hockey-prepared-search"><span>Поиск Input</span><input type="search" value="${escapeHtml(state.preparedTitleSearch)}" placeholder="номер или название"></label>
${mappingSources.length ? `<div class="hockey-prepared-recommended"><span>Подходят для ${escapeHtml(panel?.label || "блока")}</span>${mappingSources.map((item) => `<button type="button" data-prepared-mapping-source="${escapeHtml(String(item.key || item.number || item.title || ""))}"><b>#${escapeHtml(item.number || "—")}</b><span>${escapeHtml(item.title || "Input")}</span><small>${escapeHtml(item.mapped_fields || 0)} связей</small></button>`).join("")}</div>` : ""}
<div class="hockey-prepared-input-list">${filtered.map((item) => {
const identity = hockeyPreparedSourceIdentity(item);
const active = selected && hockeyPreparedSourceIdentity(selected) === identity;
return `<button type="button" class="${active ? "active" : ""}" data-prepared-input="${escapeHtml(identity)}"><b>#${escapeHtml(item.number || "—")}</b><span>${escapeHtml(item.title || "Без названия")}</span><small>${escapeHtml(item.type || "")}${Array.isArray(item.fields) ? ` · ${item.fields.length}` : ""}</small></button>`;
}).join("") || `<div class="hockey-prepared-empty">Нет Inputs с редактируемыми элементами</div>`}</div>
</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-fields">${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 `<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-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>
</aside>
</div>`;
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;