1. поправлено работа с таймерами в vMix
2. поправлено немного Заготовки
This commit is contained in:
@@ -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