поправлены удаления и надеюсь заробатаю статусы для кнопок которые вызывают титры из веб-интерфейса

This commit is contained in:
2026-08-20 13:09:15 +03:00
parent 484c2363c9
commit 3cea13c103
3 changed files with 282 additions and 17 deletions

View File

@@ -99,6 +99,9 @@
quickPanelActiveTab: "",
vmixOverlayRuntime: new Map(),
quickPanelOnAirSequences: new Set(),
quickPanelServerOnAirSequences: new Set(),
quickPanelOverlayPollTimer: null,
quickPanelOverlayPollPending: false,
triggerEditorOpenIds: new Set(),
shortcutSequenceOpenId: "",
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
@@ -4315,6 +4318,7 @@ function startCustomTooltips() {
function shortcutSequenceIsOnAir(sequenceId) {
const id = String(sequenceId || "");
if (!id) return false;
if (state.quickPanelServerOnAirSequences.has(id)) return true;
const sequence = shortcutSequenceById(id);
const targets = shortcutSequenceOverlayTargets(sequence);
// BUILD58: first compare the actual Input currently tracked on the Overlay layer
@@ -4342,6 +4346,74 @@ function startCustomTooltips() {
});
}
function applyServerRuntimeOverlayState(payload) {
const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : payload;
const overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {};
const previousServerIds = new Set(state.quickPanelServerOnAirSequences);
const nextServerIds = new Set();
for (const layer of ["1", "2", "3", "4"]) {
const raw = overlays[layer];
if (raw && typeof raw === "object") {
const entry = {
input: String(raw.input || ""),
sequence_id: String(raw.sequence_id || ""),
sequence_name: String(raw.sequence_name || ""),
button_id: String(raw.button_id || ""),
server_confirmed: true,
};
state.vmixOverlayRuntime.set(layer, entry);
if (entry.sequence_id) nextServerIds.add(entry.sequence_id);
} else {
const current = state.vmixOverlayRuntime.get(layer);
if (current?.server_confirmed) state.vmixOverlayRuntime.delete(layer);
}
}
state.quickPanelServerOnAirSequences = nextServerIds;
previousServerIds.forEach((sequenceId) => {
if (nextServerIds.has(sequenceId)) return;
if (!sequenceStillOwnsRuntimeOverlay(sequenceId)) {
state.quickPanelOnAirSequences.delete(sequenceId);
state.shortcutSequenceOverlayState.set(sequenceId, false);
}
});
nextServerIds.forEach((sequenceId) => {
state.quickPanelOnAirSequences.add(sequenceId);
state.shortcutSequenceOverlayState.set(sequenceId, true);
});
refreshQuickPanelOnAirClasses();
}
async function pollQuickPanelOverlayState({ force = false } = {}) {
if (state.quickPanelOverlayPollPending && !force) return false;
const deviceId = currentRuntimeVmixDeviceId();
if (!deviceId) return false;
state.quickPanelOverlayPollPending = true;
try {
const response = await fetch(`/api/hockey/vmix/overlay-state?device_id=${encodeURIComponent(deviceId)}`, {
method: "GET", cache: "no-store", credentials: "same-origin",
});
if (!response.ok) return false;
const payload = await response.json();
applyServerRuntimeOverlayState(payload);
return true;
} catch (_) {
return false;
} finally {
state.quickPanelOverlayPollPending = false;
}
}
function startQuickPanelOverlayPolling() {
if (state.quickPanelOverlayPollTimer) window.clearInterval(state.quickPanelOverlayPollTimer);
pollQuickPanelOverlayState({ force: true }).catch(() => {});
state.quickPanelOverlayPollTimer = window.setInterval(() => {
pollQuickPanelOverlayState().catch(() => {});
}, 1000);
}
function trackRuntimeOverlayCommands(commands, execution = null) {
const owner = execution && typeof execution === "object" ? execution : {};
const ownerSequenceId = String(owner.sequence_id || "").trim();
@@ -4422,6 +4494,9 @@ function startCustomTooltips() {
commands: clean,
device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(),
sequence_id: String(execution?.sequence_id || ""),
sequence_name: String(execution?.sequence_name || ""),
button_id: String(execution?.button_id || ""),
}),
});
let payload = {};
@@ -4431,6 +4506,7 @@ function startCustomTooltips() {
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
trackRuntimeOverlayCommands(clean, execution);
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
return payload;
}
@@ -4541,6 +4617,34 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
}
// BUILD83: HOME/AWAY penalty targets describe where the extra scorebug plate
// is drawn, not which bench committed the penalty. While both teams have an
// active penalty we keep the traditional one-per-side display (coincidental
// penalties). As soon as only one team still has a penalty, its timer must
// move to the OPPOSITE target because that is the team playing on the power
// play. Example: HOME 1:43 + HOME 2:00 + AWAY 1:43 -> after the coincidental
// 1:43 pair expires, the remaining HOME 2:00 is shown through the AWAY input.
function penaltyDisplayEntriesByTargetSide(step) {
const home = sortedPenaltyEntries("home");
const away = sortedPenaltyEntries("away");
const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all";
// Legacy BUILD43 equivalent was `allEntries.slice(0, 1)`; routing is now
// calculated for both sides together so the remaining penalty can move to
// the power-play team target without losing the shortest-time behaviour.
if (!soonestOnly) return { home, away, routedToAdvantage: false };
if (home.length && away.length) {
return { home: home.slice(0, 1), away: away.slice(0, 1), routedToAdvantage: false };
}
if (home.length) {
return { home: [], away: home.slice(0, 1), routedToAdvantage: true };
}
if (away.length) {
return { home: away.slice(0, 1), away: [], routedToAdvantage: true };
}
return { home: [], away: [], routedToAdvantage: false };
}
function hockeyPenaltySideMappingDetail(item, side) {
if (!item?.event) return null;
const event = item.event;
@@ -4633,11 +4737,11 @@ function startCustomTooltips() {
continue;
}
if (step.penalty_vmix_mode !== "text") continue;
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
for (const side of ["home", "away"]) {
const allEntries = sortedPenaltyEntries(side);
const allTargets = sequencePenaltyTargets(step, side);
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
const entries = soonestOnly ? allEntries.slice(0, 1) : allEntries;
const entries = Array.isArray(displayPlan[side]) ? displayPlan[side] : [];
const targets = soonestOnly ? allTargets.slice(0, 1) : allTargets;
const inactiveTargets = soonestOnly ? allTargets.slice(1) : [];
targets.forEach((target, index) => {
@@ -4650,7 +4754,7 @@ function startCustomTooltips() {
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
stepId: step.id,
targetId: target.id,
side,
side: String(entry.side || entry.event?.side || entry.event?.player?.side || side),
overlay: target.overlay,
});
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
@@ -4658,6 +4762,8 @@ function startCustomTooltips() {
input: target.input,
selectedName: target.selected_name,
overlay: target.overlay,
sourceSide: String(entry.side || entry.event?.side || entry.event?.player?.side || ""),
targetSide: side,
});
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
const mirror = state.vmixPenaltyMirrors.get(eventKey);
@@ -4668,8 +4774,6 @@ function startCustomTooltips() {
// If the scoreboard is already on air and this penalty target was not
// previously assigned, bring the penalty plate on air immediately.
// Previously the text was updated here, but OverlayIn happened only
// when the whole scoreboard shortcut was executed again.
if (hockeyScoreboardIsLive()) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
const targetWasVisible = Boolean(previous?.input)
@@ -4819,25 +4923,21 @@ function startCustomTooltips() {
}
if (step.sync_vmix_penalties) {
let sortedHome = penalties.filter((item) => item.side === "home").sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
let sortedAway = penalties.filter((item) => item.side === "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 displayPlan = penaltyDisplayEntriesByTargetSide(step);
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets;
const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets;
if (soonestOnly) {
sortedHome = sortedHome.slice(0, 1);
sortedAway = sortedAway.slice(0, 1);
}
state.activeHockeyVmixTimerSteps.add(step.id);
[["home", sortedHome, activeHomeTargets], ["away", sortedAway, activeAwayTargets]].forEach(([side, sideEntries, targets]) => {
[["home", displayPlan.home, activeHomeTargets], ["away", displayPlan.away, activeAwayTargets]].forEach(([side, sideEntries, targets]) => {
sideEntries.forEach(({ component, event }, index) => {
const target = targets[index] || null;
if (!target?.input) return;
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
if (step.penalty_vmix_mode === "text") {
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side, overlay: target.overlay });
const sourceSide = String(event.player?.side || event.side || side);
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay,
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side,
});
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
} else if (pausing) {
@@ -12599,13 +12699,13 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
</div>
<div class="shortcut-section-title shortcut-section-title-with-action">
<span>Таймеры удалений HOME</span>
<span>Плашка удаления / большинства HOME</span>
<button type="button" class="mini-btn" data-penalty-target-add="home">+ Добавить слот HOME</button>
</div>
<div class="shortcut-penalty-target-list" data-penalty-target-list="home">${penaltyTargetEditorRows(step, "home")}</div>
<div class="shortcut-section-title shortcut-section-title-with-action">
<span>Таймеры удалений AWAY</span>
<span>Плашка удаления / большинства AWAY</span>
<button type="button" class="mini-btn" data-penalty-target-add="away">+ Добавить слот AWAY</button>
</div>
<div class="shortcut-penalty-target-list" data-penalty-target-list="away">${penaltyTargetEditorRows(step, "away")}</div>
@@ -12624,7 +12724,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление на сторону: при двойном штрафе в поле идёт минимальное оставшееся время, а после его завершения то же поле автоматически переключается на следующий штраф. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. Пока удаления есть у обеих команд, HOME/AWAY показываются по своим сторонам. Когда штраф остаётся только у одной команды, его таймер автоматически переезжает на Input противоположной команды — стороны большинства. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
</div>`;
} else if (step.type === "delay") {
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;
@@ -13489,6 +13589,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
el.runtimeView.classList.remove("hidden");
el.closePreviewBtn.classList.add("hidden");
renderRuntime();
startQuickPanelOverlayPolling();
window.UIBuilderRuntime?.patchData?.({ hockey: { ui: { active_tab: state.activeTab, previous_tab: "" } } }, { render: false });
rememberUiNavigationState(PROJECT_TABS_ACTION_ID, "project_tab", state.activeTab, state.activeTab, { emit: false });
hockeyRefreshVmixMappingForTab(state.activeTab).catch(() => {});