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으로 실행");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user