feat: add safe Tornado K3D playout adapter
This commit is contained in:
388
Web/app.js
388
Web/app.js
@@ -36,7 +36,23 @@
|
||||
selectedRows: new Set(),
|
||||
currentIndex: -1,
|
||||
dragIndex: -1,
|
||||
playout: "IDLE",
|
||||
playout: {
|
||||
mode: "unknown",
|
||||
engineState: "IDLE",
|
||||
processDetected: false,
|
||||
connected: false,
|
||||
liveTakeInAllowed: false,
|
||||
message: "송출 어댑터 상태를 확인하고 있습니다.",
|
||||
preparedCode: null,
|
||||
onAirCode: null,
|
||||
preparedCue: null,
|
||||
changedAt: null,
|
||||
changedAtMs: 0,
|
||||
latestStatusRequestId: null,
|
||||
pending: null,
|
||||
error: null,
|
||||
lastSignature: ""
|
||||
},
|
||||
database: { sources: [], loading: true, lastSignature: "" },
|
||||
liveData: {
|
||||
requestId: null,
|
||||
@@ -66,7 +82,22 @@
|
||||
playlistBody: document.querySelector("#playlistBody"),
|
||||
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
|
||||
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
|
||||
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
|
||||
playoutState: document.querySelector("#playoutState"),
|
||||
playoutSummary: document.querySelector("#playoutSummary"),
|
||||
playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
|
||||
playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
|
||||
playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
|
||||
tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
|
||||
tornadoProcessState: document.querySelector("#tornadoProcessState"),
|
||||
playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
|
||||
playoutConnectionState: document.querySelector("#playoutConnectionState"),
|
||||
playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
|
||||
playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
|
||||
playoutError: document.querySelector("#playoutError"),
|
||||
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
|
||||
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
|
||||
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
|
||||
eventLog: document.querySelector("#eventLog"),
|
||||
toast: document.querySelector("#toast")
|
||||
};
|
||||
|
||||
@@ -207,10 +238,12 @@
|
||||
if (!item) {
|
||||
elements.previewTitle.textContent = "선택된 그래픽 없음";
|
||||
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
|
||||
renderPlayout();
|
||||
return;
|
||||
}
|
||||
elements.previewTitle.textContent = item.title;
|
||||
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
|
||||
renderPlayout();
|
||||
}
|
||||
|
||||
function onDragStart(event) {
|
||||
@@ -294,34 +327,312 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setPlayout(nextState, message) {
|
||||
state.playout = nextState;
|
||||
elements.playoutState.textContent = nextState;
|
||||
elements.playoutState.classList.toggle("neutral", nextState === "IDLE");
|
||||
addLog(message);
|
||||
function normalizePlayoutState(value) {
|
||||
const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
|
||||
if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
|
||||
if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
|
||||
if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
|
||||
return "IDLE";
|
||||
}
|
||||
|
||||
function prepare() {
|
||||
if (!state.playlist[state.currentIndex]) return showToast("먼저 그래픽 항목을 선택하세요.");
|
||||
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} PREPARE`);
|
||||
function normalizePlayoutMode(value) {
|
||||
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
|
||||
}
|
||||
|
||||
function takeIn() {
|
||||
if (state.playout !== "PREPARED") return showToast("PREPARE가 먼저 필요합니다.");
|
||||
setPlayout("PROGRAM", `${state.playlist[state.currentIndex].code} TAKE IN · Tornado 어댑터 대기`);
|
||||
showToast("UI 동작 확인 완료 — 실제 송출 어댑터는 아직 연결되지 않았습니다.");
|
||||
function isDryRunMode(mode = state.playout.mode) {
|
||||
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (!state.playlist.length) return showToast("플레이리스트가 비어 있습니다.");
|
||||
state.currentIndex = (state.currentIndex + 1) % state.playlist.length;
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} NEXT / PREPARE`);
|
||||
function isLiveMode(mode = state.playout.mode) {
|
||||
return /live|program/.test(normalizePlayoutMode(mode));
|
||||
}
|
||||
|
||||
function takeOut() {
|
||||
setPlayout("IDLE", "TAKE OUT");
|
||||
function isTestMode(mode = state.playout.mode) {
|
||||
return /test/.test(normalizePlayoutMode(mode));
|
||||
}
|
||||
|
||||
function playoutDisplayState() {
|
||||
return isDryRunMode() ? "DRY RUN" : state.playout.engineState;
|
||||
}
|
||||
|
||||
function setStatusDot(element, status) {
|
||||
element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
|
||||
element.classList.add(status);
|
||||
}
|
||||
|
||||
function cueFromItem(item) {
|
||||
if (!item) return null;
|
||||
return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
|
||||
}
|
||||
|
||||
function findCueByCode(code) {
|
||||
if (!code) return null;
|
||||
const current = state.playlist[state.currentIndex];
|
||||
if (String(current?.code) === String(code)) return cueFromItem(current);
|
||||
return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
|
||||
}
|
||||
|
||||
function renderPlayout() {
|
||||
const displayState = playoutDisplayState();
|
||||
const pending = state.playout.pending;
|
||||
const dryRun = isDryRunMode();
|
||||
const liveMode = isLiveMode();
|
||||
const testMode = isTestMode();
|
||||
const bridgeReady = Boolean(nativeBridge);
|
||||
const outcomeUnknown = state.playout.error?.outcomeUnknown === true;
|
||||
const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun);
|
||||
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
|
||||
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
|
||||
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
|
||||
|
||||
elements.playoutState.textContent = displayState;
|
||||
elements.playoutState.classList.remove("neutral", "live", "dry-run", "error");
|
||||
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
|
||||
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
|
||||
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
|
||||
|
||||
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
|
||||
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||||
elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결";
|
||||
setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||||
|
||||
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
|
||||
safetyContainer.classList.remove("live-allowed", "live-locked");
|
||||
if (dryRun) {
|
||||
elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
|
||||
} else if (testMode && state.playout.liveTakeInAllowed) {
|
||||
elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
|
||||
safetyContainer.classList.add("live-allowed");
|
||||
} else if (testMode) {
|
||||
elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
|
||||
safetyContainer.classList.add("live-locked");
|
||||
} else if (liveMode && state.playout.liveTakeInAllowed) {
|
||||
elements.playoutSafetyMode.textContent = "LIVE 허용";
|
||||
safetyContainer.classList.add("live-allowed");
|
||||
} else if (liveMode) {
|
||||
elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
|
||||
safetyContainer.classList.add("live-locked");
|
||||
} else {
|
||||
elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
|
||||
}
|
||||
|
||||
const changedLabel = state.playout.changedAtMs
|
||||
? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
|
||||
: "";
|
||||
elements.playoutStatusMessage.textContent = pending
|
||||
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
|
||||
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
|
||||
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
|
||||
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error));
|
||||
|
||||
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
|
||||
if (dryRun) {
|
||||
elements.playoutSummary.classList.add("dry-run");
|
||||
elements.playoutSummaryBadge.textContent = "DRY RUN";
|
||||
elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용";
|
||||
} else if (state.playout.connected) {
|
||||
elements.playoutSummary.classList.add("ready");
|
||||
elements.playoutSummaryBadge.textContent = testMode
|
||||
? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
|
||||
: (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
|
||||
elements.playoutSummaryDetail.textContent = testMode
|
||||
? "테스트 전용 출력 · 안전 게이트 적용"
|
||||
: (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지");
|
||||
} else {
|
||||
elements.playoutSummary.classList.add("error");
|
||||
elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
|
||||
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
|
||||
}
|
||||
|
||||
elements.prepareButton.disabled = !commandReady || !currentCue;
|
||||
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
|
||||
elements.nextButton.disabled = !commandReady || state.playlist.length === 0;
|
||||
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
|
||||
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
|
||||
button.setAttribute("aria-busy", String(Boolean(pending)));
|
||||
}
|
||||
|
||||
elements.playoutError.hidden = !state.playout.error;
|
||||
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
|
||||
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기";
|
||||
}
|
||||
|
||||
function clearPlayoutError(render = true) {
|
||||
state.playout.error = null;
|
||||
if (render) renderPlayout();
|
||||
}
|
||||
|
||||
function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
|
||||
const details = [message || "송출 명령을 완료하지 못했습니다."];
|
||||
if (code) details.push(`[${code}]`);
|
||||
if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
|
||||
else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
|
||||
state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
|
||||
renderPlayout();
|
||||
showToast(state.playout.error.message);
|
||||
}
|
||||
|
||||
function resolveCommand(command) {
|
||||
const current = state.playlist[state.currentIndex];
|
||||
if (command === "prepare") {
|
||||
return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
|
||||
}
|
||||
if (command === "take-in") {
|
||||
const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
|
||||
return cue ? { cue, targetIndex: state.currentIndex } : null;
|
||||
}
|
||||
if (command === "next") {
|
||||
if (!state.playlist.length) return null;
|
||||
const targetIndex = state.currentIndex < 0 ? 0 : (state.currentIndex + 1) % state.playlist.length;
|
||||
return { cue: cueFromItem(state.playlist[targetIndex]), targetIndex };
|
||||
}
|
||||
if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestPlayoutCommand(command) {
|
||||
if (!nativeBridge) {
|
||||
showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
if (state.playout.pending) return;
|
||||
|
||||
const resolved = resolveCommand(command);
|
||||
if (!resolved) {
|
||||
const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
|
||||
showToast(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = createId();
|
||||
const pending = {
|
||||
requestId,
|
||||
command,
|
||||
cue: resolved.cue,
|
||||
targetIndex: resolved.targetIndex,
|
||||
timeoutId: null
|
||||
};
|
||||
state.playout.pending = pending;
|
||||
clearPlayoutError(false);
|
||||
renderPlayout();
|
||||
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
|
||||
|
||||
const payload = { requestId, command };
|
||||
if (resolved.cue) payload.cue = resolved.cue;
|
||||
postNative("playout-command", payload);
|
||||
|
||||
pending.timeoutId = setTimeout(() => {
|
||||
if (state.playout.pending?.requestId !== requestId) return;
|
||||
state.playout.pending = null;
|
||||
showPlayoutError({
|
||||
code: "WEB_TIMEOUT",
|
||||
message: "송출 엔진 응답 시간이 초과되었습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: true
|
||||
});
|
||||
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
|
||||
requestPlayoutStatus();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function requestPlayoutStatus() {
|
||||
if (!nativeBridge) return;
|
||||
const requestId = createId();
|
||||
state.playout.latestStatusRequestId = requestId;
|
||||
postNative("request-playout-status", { requestId });
|
||||
}
|
||||
|
||||
function parseChangedAt(value) {
|
||||
const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function handlePlayoutStatus(payload) {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
|
||||
|
||||
const changedAtMs = parseChangedAt(payload.changedAt);
|
||||
if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;
|
||||
|
||||
state.playout.mode = normalizePlayoutMode(payload.mode);
|
||||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||||
state.playout.processDetected = payload.processDetected === true;
|
||||
state.playout.connected = payload.connected === true;
|
||||
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
|
||||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: "Tornado 송출 상태를 수신했습니다.";
|
||||
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
|
||||
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
|
||||
state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
|
||||
state.playout.changedAt = payload.changedAt || null;
|
||||
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
|
||||
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
|
||||
|
||||
renderPlayout();
|
||||
const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|");
|
||||
if (signature !== state.playout.lastSignature) {
|
||||
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
|
||||
state.playout.lastSignature = signature;
|
||||
}
|
||||
}
|
||||
|
||||
function finishPendingCommand(payload) {
|
||||
const pending = state.playout.pending;
|
||||
if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null;
|
||||
clearTimeout(pending.timeoutId);
|
||||
state.playout.pending = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function handlePlayoutCommandResult(payload) {
|
||||
const pending = finishPendingCommand(payload);
|
||||
if (!pending) return;
|
||||
if (payload.succeeded !== true) {
|
||||
showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
|
||||
addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
|
||||
requestPlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
clearPlayoutError(false);
|
||||
if (payload.dryRun === true) state.playout.mode = "dry-run";
|
||||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: `${pending.command.toUpperCase()} 완료`;
|
||||
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
|
||||
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
|
||||
|
||||
if (pending.command === "prepare" || pending.command === "next") {
|
||||
state.playout.preparedCue = pending.cue;
|
||||
} else if (!state.playout.preparedCode) {
|
||||
state.playout.preparedCue = null;
|
||||
}
|
||||
if (pending.command === "next") {
|
||||
state.currentIndex = pending.targetIndex;
|
||||
renderPlaylist();
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
renderPlayout();
|
||||
const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
|
||||
addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
|
||||
showToast(state.playout.message);
|
||||
requestPlayoutStatus();
|
||||
}
|
||||
|
||||
function handlePlayoutCommandError(payload) {
|
||||
const pending = finishPendingCommand(payload);
|
||||
if (!pending) return;
|
||||
showPlayoutError({
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
retryable: payload.retryable === true,
|
||||
outcomeUnknown: payload.outcomeUnknown === true
|
||||
});
|
||||
addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
|
||||
requestPlayoutStatus();
|
||||
}
|
||||
|
||||
function addLog(message) {
|
||||
@@ -715,6 +1026,15 @@
|
||||
case "market-data-error":
|
||||
handleMarketDataError(message.payload);
|
||||
break;
|
||||
case "playout-status":
|
||||
handlePlayoutStatus(message.payload);
|
||||
break;
|
||||
case "playout-command-result":
|
||||
handlePlayoutCommandResult(message.payload);
|
||||
break;
|
||||
case "playout-command-error":
|
||||
handlePlayoutCommandError(message.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,14 +1065,16 @@
|
||||
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
|
||||
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
|
||||
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
|
||||
document.querySelector("#prepareButton").addEventListener("click", prepare);
|
||||
document.querySelector("#takeInButton").addEventListener("click", takeIn);
|
||||
document.querySelector("#nextButton").addEventListener("click", next);
|
||||
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
|
||||
elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
|
||||
elements.takeInButton.addEventListener("click", () => requestPlayoutCommand("take-in"));
|
||||
elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
|
||||
elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
|
||||
document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
|
||||
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
|
||||
document.querySelector("#requestInfoButton").addEventListener("click", () => {
|
||||
postNative("request-app-info");
|
||||
requestDatabaseStatus();
|
||||
requestPlayoutStatus();
|
||||
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
|
||||
});
|
||||
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
|
||||
@@ -764,9 +1086,9 @@
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault(); elements.catalogSearch.focus(); return;
|
||||
}
|
||||
if (event.key === "F2") { event.preventDefault(); prepare(); }
|
||||
if (event.key === "F8") { event.preventDefault(); takeIn(); }
|
||||
if (event.key === "Escape") { event.preventDefault(); takeOut(); }
|
||||
if (event.key === "F2") { event.preventDefault(); requestPlayoutCommand("prepare"); }
|
||||
if (event.key === "F8") { event.preventDefault(); requestPlayoutCommand("take-in"); }
|
||||
if (event.key === "Escape") { event.preventDefault(); requestPlayoutCommand("take-out"); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -776,6 +1098,7 @@
|
||||
renderPlaylist();
|
||||
renderDatabaseStatus();
|
||||
renderLiveData();
|
||||
renderPlayout();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
addLog("WebView 운영 화면 시작");
|
||||
@@ -785,11 +1108,16 @@
|
||||
postNative("ready");
|
||||
postNative("request-app-info");
|
||||
requestDatabaseStatus();
|
||||
requestPlayoutStatus();
|
||||
} else {
|
||||
elements.bridgeState.textContent = "브라우저 미리보기";
|
||||
state.database.loading = false;
|
||||
state.playout.mode = "preview-dry-run";
|
||||
state.playout.engineState = "IDLE";
|
||||
state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
|
||||
renderPlayout();
|
||||
renderDatabaseStatus();
|
||||
addLog("Native Bridge 없이 브라우저 모드로 실행");
|
||||
addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<article><span class="metric-icon mint">✓</span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
|
||||
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
|
||||
<article id="databaseSummary"><span id="databaseSummaryIcon" class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small id="databaseSummaryDetail">연결 상태 확인 중</small></div><b id="databaseSummaryBadge">CHECKING</b></article>
|
||||
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
|
||||
<article id="playoutSummary" class="playout-summary"><span id="playoutSummaryIcon" class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small id="playoutSummaryDetail">상태 확인 중</small></div><b id="playoutSummaryBadge">CHECKING</b></article>
|
||||
</section>
|
||||
|
||||
<div class="console-grid">
|
||||
@@ -121,6 +121,27 @@
|
||||
<div><p class="panel-kicker">PROGRAM / PREVIEW</p><h2>송출 제어</h2></div>
|
||||
<span id="playoutState" class="badge neutral">IDLE</span>
|
||||
</div>
|
||||
<div class="playout-health" aria-label="Tornado 송출 연결 상태">
|
||||
<div class="playout-health-item">
|
||||
<span id="tornadoProcessDot" class="status-dot pending"></span>
|
||||
<small>Tornado2 Process</small>
|
||||
<strong id="tornadoProcessState">확인 중</strong>
|
||||
</div>
|
||||
<div class="playout-health-item">
|
||||
<span id="playoutConnectionDot" class="status-dot pending"></span>
|
||||
<small>Engine</small>
|
||||
<strong id="playoutConnectionState">확인 중</strong>
|
||||
</div>
|
||||
<div class="playout-health-item safety">
|
||||
<small>Safety Mode</small>
|
||||
<strong id="playoutSafetyMode">확인 중</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div id="playoutStatusMessage" class="playout-status-message" role="status" aria-live="polite">송출 어댑터 상태를 확인하고 있습니다.</div>
|
||||
<div id="playoutError" class="playout-error" role="alert" hidden>
|
||||
<div><strong>송출 명령 오류</strong><span id="playoutErrorMessage"></span></div>
|
||||
<button id="clearPlayoutErrorButton" type="button" aria-label="송출 오류 닫기">닫기</button>
|
||||
</div>
|
||||
<div class="preview-stage" id="previewStage">
|
||||
<div class="safe-area">
|
||||
<span>PREVIEW</span>
|
||||
|
||||
@@ -133,6 +133,12 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
|
||||
.database-summary.healthy > b { color: var(--mint); }
|
||||
.database-summary.partial > b { color: var(--amber); }
|
||||
.database-summary.error > b { color: var(--red); }
|
||||
.playout-summary.ready { border-color: rgba(50,213,164,.22); }
|
||||
.playout-summary.dry-run { border-color: rgba(255,186,85,.22); }
|
||||
.playout-summary.error { border-color: rgba(255,102,128,.24); }
|
||||
.playout-summary.ready > b { color: var(--mint); }
|
||||
.playout-summary.dry-run > b { color: var(--amber); }
|
||||
.playout-summary.error > b { color: var(--red); }
|
||||
|
||||
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
|
||||
|
||||
@@ -142,6 +148,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
|
||||
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
|
||||
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
|
||||
.badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); }
|
||||
.badge.dry-run { border-color: rgba(255,186,85,.32); background: rgba(255,186,85,.11); color: var(--amber); }
|
||||
|
||||
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
|
||||
.search-box > span { color: var(--muted); font-size: 17px; }
|
||||
@@ -239,6 +246,23 @@ input[type="checkbox"] { accent-color: var(--mint); }
|
||||
.empty-state span { color: var(--muted-2); font-size: 9px; }
|
||||
|
||||
.playout-panel { padding-bottom: 12px; }
|
||||
.playout-health { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin: 10px 13px 0; }
|
||||
.playout-health-item { display: grid; grid-template-columns: 7px minmax(0, 1fr); align-items: center; min-width: 0; min-height: 38px; column-gap: 6px; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.72); }
|
||||
.playout-health-item small { overflow: hidden; color: var(--muted-2); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playout-health-item strong { grid-column: 2; overflow: hidden; color: #b9c7d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playout-health-item.safety { grid-template-columns: 1fr; }
|
||||
.playout-health-item.safety strong { grid-column: 1; color: var(--amber); }
|
||||
.playout-health-item.safety.live-allowed strong { color: var(--mint); }
|
||||
.playout-health-item.safety.live-locked strong { color: var(--red); }
|
||||
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
|
||||
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
|
||||
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
|
||||
.playout-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 13px 0; padding: 8px 9px; border: 1px solid rgba(255,102,128,.28); border-radius: 7px; background: rgba(255,102,128,.07); }
|
||||
.playout-error[hidden] { display: none; }
|
||||
.playout-error strong, .playout-error span { display: block; }
|
||||
.playout-error strong { margin-bottom: 2px; color: #ff9bad; font-size: 8px; }
|
||||
.playout-error span { color: #c98995; font-size: 8px; line-height: 1.4; }
|
||||
.playout-error button { min-height: 24px; flex: 0 0 auto; padding: 0 7px; border: 1px solid rgba(255,102,128,.25); border-radius: 5px; background: transparent; color: #e38c9c; font-size: 8px; cursor: pointer; }
|
||||
.preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); }
|
||||
.preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; }
|
||||
.safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; }
|
||||
@@ -253,6 +277,8 @@ input[type="checkbox"] { accent-color: var(--mint); }
|
||||
.playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; }
|
||||
.playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; }
|
||||
.playout-controls button:hover { filter: brightness(1.15); }
|
||||
.playout-controls button:disabled { filter: none; cursor: not-allowed; opacity: .4; }
|
||||
.playout-controls button[aria-busy="true"] { border-color: rgba(86,168,255,.36); box-shadow: inset 0 0 0 1px rgba(86,168,255,.08); opacity: .72; }
|
||||
.playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; }
|
||||
.playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); }
|
||||
.playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; }
|
||||
|
||||
Reference in New Issue
Block a user