1846 lines
84 KiB
JavaScript
1846 lines
84 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const playoutSafety = globalThis.MbnPlayoutSafety;
|
|
if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
|
|
const DEFAULT_FADE_DURATION = 6;
|
|
|
|
const catalog = [
|
|
{ builderKey: "s5001", code: "5001", aliases: ["5001", "N5001"], title: "1열판 · 기본", detail: "국내·NXT·해외 현재가", category: "plate", market: "kospi" },
|
|
{ builderKey: "s5006", code: "5006", title: "1열판 · 시가 상세", detail: "현재·시가·고가·저가", category: "plate", market: "kosdaq" },
|
|
{ builderKey: "s5011", code: "5011", title: "1열판 · 상세", detail: "52주·시총·순위", category: "plate", market: "kospi" },
|
|
{ builderKey: "s5016", code: "5016", title: "3열판", detail: "주요 지수 3열 구성", category: "plate", market: "index" },
|
|
{ builderKey: "s50160", code: "50160", title: "2열판 · 보조", detail: "원면·금·국채 2열", category: "plate", market: "overseas" },
|
|
{ builderKey: "s5023", code: "5023", title: "매매동향 · 표", detail: "지수별 투자자 동향", category: "market", market: "index" },
|
|
{ builderKey: "s5024", code: "5024", title: "매매동향 · 막대", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
|
|
{ builderKey: "s5025", code: "5025", title: "순매도 상위", detail: "수동 상위 종목 표", category: "market", market: "kospi" },
|
|
{ builderKey: "s5026", code: "5026", title: "비교분석 · 캔들", detail: "두 종목 주간 비교", category: "chart", market: "compare" },
|
|
{ builderKey: "s5029", code: "5029", title: "수익률 비교", detail: "두 종목 수익률", category: "chart", market: "compare" },
|
|
{ builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"], title: "2열판 · 선물", detail: "지수·선물 2열", category: "plate", market: "index" },
|
|
{ builderKey: "s5037", code: "5037", title: "거래원", detail: "매수·매도 거래원", category: "market", market: "kospi" },
|
|
{ builderKey: "s5074", code: "5074", title: "5단 표", detail: "5개 단위 · 최대 20페이지", category: "plate", market: "kosdaq" },
|
|
{ builderKey: "s5076", code: "5076", title: "주요매출 구성", detail: "매출 비중 원그래프", category: "chart", market: "kospi" },
|
|
{ builderKey: "s5077", code: "5077", title: "6종목 현재가", detail: "6개 단위 · 최대 20페이지", category: "plate", market: "kospi" },
|
|
{ builderKey: "s5078", code: "5078", title: "섹터 지수", detail: "미국·국내 섹터 막대", category: "chart", market: "overseas" },
|
|
{ builderKey: "s5079", code: "5079", title: "성장성 지표", detail: "복수 성장 지표 선그래프", category: "chart", market: "kospi" },
|
|
{ builderKey: "s5080", code: "5080", title: "매출액 지표", detail: "분기별 매출 막대", category: "chart", market: "kospi" },
|
|
{ builderKey: "s5081", code: "5081", title: "영업이익 지표", detail: "분기별 영업이익", category: "chart", market: "kospi" },
|
|
{ builderKey: "s5082", code: "5082", title: "주체별 매매동향", detail: "코스피·코스닥·200", category: "market", market: "index" },
|
|
{ builderKey: "s5083", code: "5083", title: "주체별 선그래프", detail: "개인·외국인·기관", category: "chart", market: "kospi" },
|
|
{ builderKey: "s5084", code: "5084", title: "매매동향 라인", detail: "코스피·코스닥", category: "chart", market: "index" },
|
|
{ builderKey: "s5085", code: "5085", title: "프로그램 매매", detail: "구분별 프로그램 금액", category: "market", market: "index" },
|
|
{ builderKey: "s5086", code: "5086", title: "수익률 그래프", detail: "지수·종목 기간 수익률", category: "chart", market: "compare" },
|
|
{ builderKey: "s50860", code: "50860", title: "라인 그래프", detail: "지수·종목 기간 추이", category: "chart", market: "compare" },
|
|
{ builderKey: "s5087", code: "5087", title: "비교분석 · 라인", detail: "두 종목 라인 비교", category: "chart", market: "compare" },
|
|
{ builderKey: "s5088", code: "5088", title: "12종목 현재가", detail: "12개 단위 · 최대 20페이지", category: "plate", market: "theme" },
|
|
{ builderKey: "s6001", code: "6001", title: "해외증시 이미지", detail: "해외 지수·원자재", category: "market", market: "overseas" },
|
|
{ builderKey: "s6067", code: "6067", title: "기관 순매수", detail: "기관 순매수 현황", category: "market", market: "kospi" },
|
|
{ builderKey: "s8001", code: "8001", aliases: ["8001", "8002"], title: "네모 그래프", detail: "업종별 등락 큐브", category: "chart", market: "index" },
|
|
{ builderKey: "s8003", code: "8003", title: "호가창", detail: "매수·매도 10단 호가", category: "market", market: "kospi" },
|
|
{ builderKey: "s8010", code: "8035", aliases: ["8035", "8061", "8040", "8046", "8051", "8056"], title: "캔들 그래프", detail: "8035·8061·8040·8046·8051·8056", category: "chart", market: "index" },
|
|
{ builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"], title: "2열판 · 시장", detail: "국내·NXT·해외 2열", category: "plate", market: "index" },
|
|
{ builderKey: "s8067", code: "8067", aliases: ["8067", "5068", "5070", "5072"], title: "글로벌 증시 지도", detail: "8067·5068·5070·5072", category: "market", market: "overseas" },
|
|
{ builderKey: "s8086", code: "8086", title: "유가·금값", detail: "원본 MainForm 미도달 장면", category: "market", market: "exchange", reachable: false }
|
|
];
|
|
if (catalog.length !== 35) throw new Error("Legacy scene catalog must contain exactly 35 builders.");
|
|
const sceneAliases = item => Array.isArray(item?.aliases) ? item.aliases : [String(item?.code || "")];
|
|
const reachableAliases = new Set(catalog
|
|
.filter(item => item.reachable !== false)
|
|
.flatMap(sceneAliases));
|
|
if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases.");
|
|
|
|
const sceneSelectionDefaults = Object.freeze({
|
|
s5001: ["KOSPI", "삼성전자", "", "CURRENT", ""],
|
|
s5006: ["KOSPI_STOCK", "삼성전자", "", "CURRENT", "005930"],
|
|
s5011: ["KOSPI_STOCK", "삼성전자", "", "Valuation", "005930"],
|
|
s5016: ["ALL", "INDEX", "", "UnitedStatesIndices", ""],
|
|
s50160: ["ALL", "INDEX", "", "Cotton", ""],
|
|
s5023: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", ""],
|
|
s5024: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", ""],
|
|
s5025: ["INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", ""],
|
|
s5026: ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
|
|
s5029: ["STOCK", "삼성전자,SK하이닉스", "", "5일", ""],
|
|
s5032: ["INDEX", "FUTURES,Kospi", "", "CURRENT", ""],
|
|
s5037: ["KOSPI", "삼성전자", "TRADER", "TABLE_GRAPH", ""],
|
|
s5074: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
|
|
s5076: ["KOSPI", "삼성전자", "", "", ""],
|
|
s5077: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
|
|
s5078: ["FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", "", ""],
|
|
s5079: ["KOSPI", "삼성전자", "GROWTH_METRICS", "", ""],
|
|
s5080: ["KOSPI", "삼성전자", "SALES", "", ""],
|
|
s5081: ["KOSPI", "삼성전자", "", "", ""],
|
|
s5082: ["ALL", "INDEX", "", "", ""],
|
|
s5083: ["ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", ""],
|
|
s5084: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", ""],
|
|
s5085: ["ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", ""],
|
|
s5086: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
|
|
s50860: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
|
|
s5087: ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
|
|
s5088: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
|
|
s6001: ["FOREIGN_INDEX", "DOW", "", "DowJones", ""],
|
|
s6067: ["ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", ""],
|
|
s8001: ["ALL", "KOSPI", "", "", ""],
|
|
s8003: ["KOSPI", "삼성전자", "ORDER_BOOK", "TABLE_GRAPH", "005930"],
|
|
s8010: ["KOSPI_INDEX", "INDEX", "", "PRICE", ""],
|
|
s8018: ["INDEX", "Kospi,Kosdaq", "", "CURRENT", ""],
|
|
s8067: ["ALL", "INDEX", "", "", ""]
|
|
});
|
|
if (Object.keys(sceneSelectionDefaults).length !== 34) {
|
|
throw new Error("Every reachable legacy builder must have a Web selection preset.");
|
|
}
|
|
|
|
function localDateText() {
|
|
const now = new Date();
|
|
const year = String(now.getFullYear()).padStart(4, "0");
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const day = String(now.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function defaultSceneSelection(builderKey) {
|
|
const values = sceneSelectionDefaults[builderKey];
|
|
if (!values) return undefined;
|
|
return {
|
|
groupCode: values[0],
|
|
subject: values[1],
|
|
graphicType: values[2],
|
|
subtype: values[3],
|
|
dataCode: values[4] === "$TODAY" ? localDateText() : values[4]
|
|
};
|
|
}
|
|
|
|
const marketTitles = {
|
|
dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽",
|
|
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
|
|
compare: "종목 비교", theme: "테마 관리", expert: "전문가 추천", halted: "거래 정지"
|
|
};
|
|
const liveDataViews = new Set(["kospi", "kosdaq", "index", "overseas"]);
|
|
const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };
|
|
|
|
const storageKey = "mbn-stock-webview.playlist.v1";
|
|
const state = {
|
|
activeMarket: "dashboard",
|
|
activeCategory: "all",
|
|
search: "",
|
|
playlist: [],
|
|
selectedRows: new Set(),
|
|
currentIndex: -1,
|
|
dragIndex: -1,
|
|
playout: {
|
|
mode: "unknown",
|
|
engineState: "IDLE",
|
|
connectionState: "unavailable",
|
|
processDetected: false,
|
|
connected: false,
|
|
lastKtapConnectState: "not-attempted",
|
|
ktapConnectAttempted: false,
|
|
ktapConnectAccepted: false,
|
|
ktapHelloObserved: null,
|
|
networkMonitoringRecordExpected: false,
|
|
networkMonitoringCheckRequired: false,
|
|
commandAvailable: false,
|
|
liveTakeInAllowed: false,
|
|
nativeOutcomeUnknown: false,
|
|
browserCorrelationQuarantined: false,
|
|
sessionQuarantined: false,
|
|
operationTimeoutMs: 5000,
|
|
responseTimeoutMs: 10000,
|
|
message: "송출 어댑터 상태를 확인하고 있습니다.",
|
|
preparedCode: null,
|
|
onAirCode: null,
|
|
preparedCue: null,
|
|
currentCueIndex: -1,
|
|
currentEntryId: null,
|
|
builderKey: null,
|
|
pageIndex: 0,
|
|
pageCount: 0,
|
|
pageSize: 0,
|
|
itemCount: 0,
|
|
currentPageItemCount: 0,
|
|
isLastPage: true,
|
|
nextKind: "none",
|
|
previewFields: [],
|
|
refreshActive: false,
|
|
refreshNextAt: null,
|
|
refreshLastSuccessAt: null,
|
|
refreshFaulted: false,
|
|
refreshFaultCode: null,
|
|
refreshFaultMessage: null,
|
|
changedAt: null,
|
|
changedAtMs: 0,
|
|
latestStatusRequestId: null,
|
|
pending: null,
|
|
error: null,
|
|
lastSignature: ""
|
|
},
|
|
database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 },
|
|
liveData: {
|
|
requestId: null,
|
|
view: null,
|
|
status: "idle",
|
|
tables: [],
|
|
retrievedAt: null,
|
|
error: "",
|
|
timeoutId: null
|
|
}
|
|
};
|
|
|
|
const elements = {
|
|
bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
|
|
databaseStateDot: document.querySelector("#databaseStateDot"),
|
|
databaseSummary: document.querySelector("#databaseSummary"),
|
|
databaseSummaryIcon: document.querySelector("#databaseSummaryIcon"),
|
|
databaseSummaryDetail: document.querySelector("#databaseSummaryDetail"),
|
|
databaseSummaryBadge: document.querySelector("#databaseSummaryBadge"),
|
|
runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
|
|
workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
|
|
catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
|
|
catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"),
|
|
liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"),
|
|
liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"),
|
|
databaseSources: document.querySelector("#databaseSources"),
|
|
playlistBody: document.querySelector("#playlistBody"),
|
|
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
|
|
selectAll: document.querySelector("#selectAllButton"),
|
|
moveUpButton: document.querySelector("#moveUpButton"),
|
|
moveDownButton: document.querySelector("#moveDownButton"),
|
|
deleteButton: document.querySelector("#deleteButton"),
|
|
loadButton: document.querySelector("#loadButton"),
|
|
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
|
|
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"),
|
|
playoutSceneCode: document.querySelector("#playoutSceneCode"),
|
|
playoutEntryId: document.querySelector("#playoutEntryId"),
|
|
playoutBuilderKey: document.querySelector("#playoutBuilderKey"),
|
|
playoutPageNumber: document.querySelector("#playoutPageNumber"),
|
|
playoutPageSize: document.querySelector("#playoutPageSize"),
|
|
playoutItemCount: document.querySelector("#playoutItemCount"),
|
|
playoutCurrentRows: document.querySelector("#playoutCurrentRows"),
|
|
playoutLastPage: document.querySelector("#playoutLastPage"),
|
|
playoutNextKind: document.querySelector("#playoutNextKind"),
|
|
playoutRefreshState: document.querySelector("#playoutRefreshState"),
|
|
sceneDataPreview: document.querySelector("#sceneDataPreview"),
|
|
playoutError: document.querySelector("#playoutError"),
|
|
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
|
|
sceneSelectionForm: document.querySelector("#sceneSelectionForm"),
|
|
sceneGroupCode: document.querySelector("#sceneGroupCode"),
|
|
sceneSubject: document.querySelector("#sceneSubject"),
|
|
sceneGraphicType: document.querySelector("#sceneGraphicType"),
|
|
sceneSubtype: document.querySelector("#sceneSubtype"),
|
|
sceneDataCode: document.querySelector("#sceneDataCode"),
|
|
sceneCutAlias: document.querySelector("#sceneCutAlias"),
|
|
sceneFadeDuration: document.querySelector("#sceneFadeDuration"),
|
|
applySceneSelectionButton: document.querySelector("#applySceneSelectionButton"),
|
|
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
|
|
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
|
|
eventLog: document.querySelector("#eventLog"),
|
|
toast: document.querySelector("#toast")
|
|
};
|
|
|
|
const nativeBridge = window.chrome?.webview;
|
|
|
|
function createId() {
|
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function isPlaylistSnapshotLocked() {
|
|
return playoutSafety.playlistSnapshotLocked({
|
|
pending: Boolean(state.playout.pending),
|
|
outcomeUnknown: state.playout.sessionQuarantined ||
|
|
state.playout.nativeOutcomeUnknown ||
|
|
state.playout.error?.outcomeUnknown === true,
|
|
engineState: state.playout.engineState,
|
|
preparedCode: state.playout.preparedCode,
|
|
onAirCode: state.playout.onAirCode
|
|
});
|
|
}
|
|
|
|
function requirePlaylistEditable() {
|
|
if (!isPlaylistSnapshotLocked()) return true;
|
|
showToast("PREPARE 이후에는 TAKE OUT까지 플레이리스트가 잠깁니다.");
|
|
return false;
|
|
}
|
|
|
|
function updateClock() {
|
|
elements.clock.textContent = new Intl.DateTimeFormat("ko-KR", {
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
|
|
}).format(new Date());
|
|
}
|
|
|
|
function filteredCatalog() {
|
|
const term = state.search.trim().toLocaleLowerCase("ko-KR");
|
|
return catalog.filter(item => {
|
|
const marketMatches = state.activeMarket === "dashboard" || item.market === state.activeMarket;
|
|
const categoryMatches = state.activeCategory === "all" || item.category === state.activeCategory;
|
|
const searchMatches = !term || `${item.code} ${item.title} ${item.detail}`.toLocaleLowerCase("ko-KR").includes(term);
|
|
return marketMatches && categoryMatches && searchMatches;
|
|
});
|
|
}
|
|
|
|
function renderCatalog() {
|
|
elements.catalogList.replaceChildren();
|
|
const items = filteredCatalog();
|
|
|
|
if (!items.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "catalog-empty";
|
|
empty.textContent = "조건에 맞는 그래픽 항목이 없습니다.";
|
|
elements.catalogList.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
const row = document.createElement("article");
|
|
row.className = "catalog-item";
|
|
|
|
const code = document.createElement("span");
|
|
code.className = "cut-code";
|
|
code.textContent = item.code;
|
|
|
|
const copy = document.createElement("div");
|
|
const title = document.createElement("strong");
|
|
const detail = document.createElement("small");
|
|
title.textContent = item.title;
|
|
detail.textContent = item.detail;
|
|
copy.append(title, detail);
|
|
|
|
const add = document.createElement("button");
|
|
add.className = "add-cut";
|
|
add.type = "button";
|
|
add.disabled = item.reachable === false || isPlaylistSnapshotLocked();
|
|
add.title = item.reachable === false ? "원본 MainForm에서 도달하지 않는 장면" : "플레이리스트에 추가";
|
|
add.textContent = "+";
|
|
add.addEventListener("click", () => addToPlaylist(item));
|
|
|
|
row.append(code, copy, add);
|
|
elements.catalogList.append(row);
|
|
}
|
|
}
|
|
|
|
function addToPlaylist(item) {
|
|
if (item.reachable === false || !requirePlaylistEditable()) return;
|
|
const selection = defaultSceneSelection(item.builderKey);
|
|
const entry = {
|
|
...item,
|
|
id: createId(),
|
|
selection,
|
|
enabled: true,
|
|
fadeDuration: DEFAULT_FADE_DURATION,
|
|
graphicType: selection?.graphicType || "",
|
|
subtype: selection?.subtype || ""
|
|
};
|
|
state.playlist.push(entry);
|
|
state.currentIndex = state.playlist.length - 1;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`${item.code} ${item.title} 항목 추가`);
|
|
showToast("플레이리스트에 추가했습니다.");
|
|
}
|
|
|
|
function renderPlaylist() {
|
|
elements.playlistBody.replaceChildren();
|
|
elements.playlistCount.textContent = `${state.playlist.length} CUTS`;
|
|
elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0);
|
|
|
|
state.playlist.forEach((item, index) => {
|
|
const row = document.createElement("tr");
|
|
row.draggable = !isPlaylistSnapshotLocked();
|
|
row.dataset.index = String(index);
|
|
row.classList.toggle("active", index === state.currentIndex);
|
|
|
|
const checkCell = document.createElement("td");
|
|
checkCell.className = "check-cell";
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked = state.selectedRows.has(item.id);
|
|
checkbox.disabled = isPlaylistSnapshotLocked();
|
|
checkbox.setAttribute("aria-label", `${item.title} 선택`);
|
|
checkbox.addEventListener("click", event => event.stopPropagation());
|
|
checkbox.addEventListener("change", () => {
|
|
checkbox.checked ? state.selectedRows.add(item.id) : state.selectedRows.delete(item.id);
|
|
});
|
|
checkCell.append(checkbox);
|
|
|
|
const number = document.createElement("td");
|
|
number.className = "number-cell";
|
|
number.textContent = String(index + 1).padStart(2, "0");
|
|
|
|
const titleCell = document.createElement("td");
|
|
const title = document.createElement("strong");
|
|
const detail = document.createElement("small");
|
|
title.textContent = item.title;
|
|
detail.textContent = `${item.code} · ${item.detail}`;
|
|
titleCell.append(title, detail);
|
|
|
|
const typeCell = document.createElement("td");
|
|
const type = document.createElement("span");
|
|
type.className = "row-type";
|
|
type.textContent = item.category.toUpperCase();
|
|
const includeLabel = document.createElement("label");
|
|
includeLabel.className = "row-enabled";
|
|
const include = document.createElement("input");
|
|
include.type = "checkbox";
|
|
include.checked = item.enabled !== false;
|
|
include.disabled = isPlaylistSnapshotLocked();
|
|
include.setAttribute("aria-label", `${item.title} 송출 포함`);
|
|
include.addEventListener("click", event => event.stopPropagation());
|
|
include.addEventListener("change", event => {
|
|
event.stopPropagation();
|
|
if (!requirePlaylistEditable()) {
|
|
include.checked = item.enabled !== false;
|
|
return;
|
|
}
|
|
item.enabled = include.checked;
|
|
addLog(`${item.code} ${include.checked ? "송출 포함" : "송출 제외"}`);
|
|
});
|
|
includeLabel.append(include, document.createTextNode("송출"));
|
|
typeCell.append(type, includeLabel);
|
|
|
|
const drag = document.createElement("td");
|
|
drag.className = "drag-cell";
|
|
drag.textContent = "⠿";
|
|
|
|
row.append(checkCell, number, titleCell, typeCell, drag);
|
|
row.addEventListener("click", () => selectPlaylistRow(index));
|
|
row.addEventListener("dragstart", onDragStart);
|
|
row.addEventListener("dragover", onDragOver);
|
|
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
|
|
row.addEventListener("drop", onDrop);
|
|
row.addEventListener("dragend", onDragEnd);
|
|
elements.playlistBody.append(row);
|
|
});
|
|
}
|
|
|
|
function selectPlaylistRow(index) {
|
|
if (isPlaylistSnapshotLocked()) return;
|
|
state.currentIndex = index;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
}
|
|
|
|
function updatePreview() {
|
|
const item = state.playlist[state.currentIndex];
|
|
if (!item) {
|
|
elements.previewTitle.textContent = "선택된 그래픽 없음";
|
|
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
|
|
renderSceneSelectionForm(null);
|
|
renderPlayout();
|
|
return;
|
|
}
|
|
elements.previewTitle.textContent = item.title;
|
|
const selection = item.selection?.subject
|
|
? ` · ${item.selection.subject}${item.selection.dataCode ? ` (${item.selection.dataCode})` : ""}`
|
|
: "";
|
|
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}${selection}`;
|
|
renderSceneSelectionForm(item);
|
|
renderPlayout();
|
|
}
|
|
|
|
function renderSceneSelectionForm(item) {
|
|
const selection = item?.selection || {};
|
|
const disabled = !item || isPlaylistSnapshotLocked();
|
|
elements.sceneGroupCode.value = String(selection.groupCode || "");
|
|
elements.sceneSubject.value = String(selection.subject || "");
|
|
elements.sceneGraphicType.value = String(selection.graphicType || "");
|
|
elements.sceneSubtype.value = String(selection.subtype || "");
|
|
elements.sceneDataCode.value = String(selection.dataCode || "");
|
|
elements.sceneCutAlias.replaceChildren();
|
|
for (const alias of item ? sceneAliases(item) : []) {
|
|
const option = document.createElement("option");
|
|
option.value = alias;
|
|
option.textContent = alias;
|
|
elements.sceneCutAlias.append(option);
|
|
}
|
|
elements.sceneCutAlias.value = String(item?.code || "");
|
|
elements.sceneFadeDuration.value = String(Number.isInteger(Number(item?.fadeDuration))
|
|
? Number(item.fadeDuration)
|
|
: DEFAULT_FADE_DURATION);
|
|
for (const input of [
|
|
elements.sceneGroupCode,
|
|
elements.sceneSubject,
|
|
elements.sceneGraphicType,
|
|
elements.sceneSubtype,
|
|
elements.sceneDataCode,
|
|
elements.sceneCutAlias,
|
|
elements.sceneFadeDuration
|
|
]) input.disabled = disabled;
|
|
elements.applySceneSelectionButton.disabled = disabled;
|
|
}
|
|
|
|
function applySceneSelection(event) {
|
|
event.preventDefault();
|
|
if (!requirePlaylistEditable()) return;
|
|
const item = state.playlist[state.currentIndex];
|
|
if (!item) return;
|
|
const values = [
|
|
elements.sceneGroupCode.value,
|
|
elements.sceneSubject.value,
|
|
elements.sceneGraphicType.value,
|
|
elements.sceneSubtype.value,
|
|
elements.sceneDataCode.value
|
|
];
|
|
if (values.some(value => value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))) {
|
|
showToast("장면 조회값 형식이 올바르지 않습니다.");
|
|
return;
|
|
}
|
|
const fadeDuration = Number(elements.sceneFadeDuration.value);
|
|
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
|
showToast("FADE 값은 0~60 사이의 정수여야 합니다.");
|
|
return;
|
|
}
|
|
const cutAlias = String(elements.sceneCutAlias.value || "");
|
|
if (!sceneAliases(item).includes(cutAlias)) {
|
|
showToast("허용되지 않은 CUT alias입니다.");
|
|
return;
|
|
}
|
|
item.selection = {
|
|
groupCode: values[0].trim(),
|
|
subject: values[1].trim(),
|
|
graphicType: values[2].trim(),
|
|
subtype: values[3].trim(),
|
|
dataCode: values[4].trim()
|
|
};
|
|
item.code = cutAlias;
|
|
item.fadeDuration = fadeDuration;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`${item.code} 장면 조회값 적용`);
|
|
showToast("장면 조회값을 적용했습니다.");
|
|
}
|
|
|
|
function applyCutAliasPreset() {
|
|
const item = state.playlist[state.currentIndex];
|
|
if (!item) return;
|
|
const alias = String(elements.sceneCutAlias.value || "");
|
|
if (item.builderKey === "s5001") {
|
|
elements.sceneGroupCode.value = alias === "N5001" ? "NXT_KOSPI" : "KOSPI";
|
|
} else if (item.builderKey === "s8001") {
|
|
elements.sceneSubject.value = alias === "8002" ? "KOSDAQ" : "KOSPI";
|
|
}
|
|
}
|
|
|
|
function onDragStart(event) {
|
|
if (!requirePlaylistEditable()) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
state.dragIndex = Number(event.currentTarget.dataset.index);
|
|
event.currentTarget.classList.add("dragging");
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", String(state.dragIndex));
|
|
}
|
|
|
|
function onDragOver(event) {
|
|
if (isPlaylistSnapshotLocked()) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
event.currentTarget.classList.add("drag-over");
|
|
}
|
|
|
|
function onDrop(event) {
|
|
event.preventDefault();
|
|
if (!requirePlaylistEditable()) return;
|
|
const targetIndex = Number(event.currentTarget.dataset.index);
|
|
if (state.dragIndex < 0 || state.dragIndex === targetIndex) return;
|
|
const [moved] = state.playlist.splice(state.dragIndex, 1);
|
|
state.playlist.splice(targetIndex, 0, moved);
|
|
state.currentIndex = targetIndex;
|
|
state.dragIndex = -1;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog("플레이리스트 순서 변경");
|
|
}
|
|
|
|
function onDragEnd() {
|
|
state.dragIndex = -1;
|
|
document.querySelectorAll("tr.dragging, tr.drag-over").forEach(row => row.classList.remove("dragging", "drag-over"));
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
if (!requirePlaylistEditable()) return;
|
|
const allSelected = state.playlist.length > 0 && state.playlist.every(item => state.selectedRows.has(item.id));
|
|
state.selectedRows.clear();
|
|
if (!allSelected) state.playlist.forEach(item => state.selectedRows.add(item.id));
|
|
renderPlaylist();
|
|
}
|
|
|
|
function moveSelected(direction) {
|
|
if (!requirePlaylistEditable()) return;
|
|
const index = state.playlist.findIndex(item => state.selectedRows.has(item.id));
|
|
if (index < 0) return showToast("이동할 항목을 선택하세요.");
|
|
const target = Math.max(0, Math.min(state.playlist.length - 1, index + direction));
|
|
if (index === target) return;
|
|
[state.playlist[index], state.playlist[target]] = [state.playlist[target], state.playlist[index]];
|
|
state.currentIndex = target;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!requirePlaylistEditable()) return;
|
|
if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요.");
|
|
const deleted = state.selectedRows.size;
|
|
state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id));
|
|
state.selectedRows.clear();
|
|
state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1);
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`${deleted}개 항목 삭제`);
|
|
}
|
|
|
|
function savePlaylist() {
|
|
localStorage.setItem(storageKey, JSON.stringify(state.playlist));
|
|
addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`);
|
|
showToast("플레이리스트를 저장했습니다.");
|
|
}
|
|
|
|
function normalizeStoredSelection(value) {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
const result = {};
|
|
for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) {
|
|
const text = typeof value[key] === "string" ? value[key] : "";
|
|
if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return undefined;
|
|
result[key] = text;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeStoredPlaylist(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.slice(0, 1000).flatMap(item => {
|
|
if (!item || typeof item !== "object") return [];
|
|
const storedCode = String(item.code || "");
|
|
const definitionIndex = playoutSafety.resolveStoredCatalogIndex(
|
|
catalog,
|
|
item.builderKey,
|
|
storedCode);
|
|
const definition = definitionIndex >= 0 ? catalog[definitionIndex] : null;
|
|
if (!definition) return [];
|
|
const id = typeof item.id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(item.id)
|
|
? item.id
|
|
: createId();
|
|
const selection = normalizeStoredSelection(item.selection) ||
|
|
defaultSceneSelection(definition.builderKey);
|
|
const fadeDuration = Number(item.fadeDuration);
|
|
return [{
|
|
...definition,
|
|
id,
|
|
code: sceneAliases(definition).includes(storedCode) ? storedCode : definition.code,
|
|
enabled: item.enabled !== false,
|
|
fadeDuration: Number.isInteger(fadeDuration) && fadeDuration >= 0 && fadeDuration <= 60
|
|
? fadeDuration
|
|
: DEFAULT_FADE_DURATION,
|
|
...(selection ? {
|
|
selection,
|
|
graphicType: selection.graphicType,
|
|
subtype: selection.subtype
|
|
} : {})
|
|
}];
|
|
});
|
|
}
|
|
|
|
function loadPlaylist() {
|
|
if (!requirePlaylistEditable()) return;
|
|
try {
|
|
const stored = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
|
state.playlist = normalizeStoredPlaylist(stored);
|
|
state.selectedRows.clear();
|
|
state.currentIndex = state.playlist.length ? 0 : -1;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`플레이리스트 ${state.playlist.length}개 불러오기`);
|
|
showToast("저장된 플레이리스트를 불러왔습니다.");
|
|
} catch {
|
|
showToast("저장 데이터를 읽을 수 없습니다.");
|
|
}
|
|
}
|
|
|
|
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 normalizePlayoutMode(value) {
|
|
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
|
|
}
|
|
|
|
function normalizeScenePreviewFields(value) {
|
|
if (!Array.isArray(value) || value.length > 4096) return [];
|
|
return value.flatMap(field => {
|
|
if (!field || typeof field !== "object") return [];
|
|
const kind = typeof field.kind === "string" ? field.kind : "";
|
|
const name = typeof field.name === "string" ? field.name : "";
|
|
const text = typeof field.value === "string" ? field.value : "";
|
|
if (!/^[a-z-]{1,32}$/.test(kind) || !name || name.length > 128 ||
|
|
text.length > 16384 || /[\u0000\r\n]/.test(name + text)) return [];
|
|
return [{ kind, name, value: text }];
|
|
});
|
|
}
|
|
|
|
function applyRefreshState(payload) {
|
|
state.playout.refreshActive = payload.refreshActive === true;
|
|
state.playout.refreshNextAt = payload.refreshNextAt || null;
|
|
state.playout.refreshLastSuccessAt = payload.refreshLastSuccessAt || null;
|
|
state.playout.refreshFaulted = payload.refreshFaulted === true;
|
|
state.playout.refreshFaultCode = typeof payload.refreshFaultCode === "string"
|
|
? payload.refreshFaultCode
|
|
: null;
|
|
state.playout.refreshFaultMessage = typeof payload.refreshFaultMessage === "string"
|
|
? payload.refreshFaultMessage
|
|
: null;
|
|
if (state.playout.refreshFaulted && state.playout.error?.outcomeUnknown !== true) {
|
|
state.playout.error = {
|
|
code: state.playout.refreshFaultCode || "REFRESH_FAULT",
|
|
message: state.playout.refreshFaultMessage ||
|
|
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
|
|
retryable: false,
|
|
outcomeUnknown: false,
|
|
nonClearable: true,
|
|
isRefreshFault: true
|
|
};
|
|
} else if (playoutSafety.shouldClearRefreshError(
|
|
state.playout.error,
|
|
state.playout.refreshFaulted)) {
|
|
state.playout.error = null;
|
|
}
|
|
}
|
|
|
|
function renderSceneDataPreview() {
|
|
elements.sceneDataPreview.replaceChildren();
|
|
if (state.playout.previewFields.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "scene-data-empty";
|
|
empty.textContent = "PREPARE 후 실제 DTO → mutation 값을 표시합니다.";
|
|
elements.sceneDataPreview.append(empty);
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
for (const field of state.playout.previewFields) {
|
|
const row = document.createElement("div");
|
|
row.className = "scene-data-row";
|
|
const kind = document.createElement("span");
|
|
kind.textContent = field.kind;
|
|
const name = document.createElement("strong");
|
|
name.textContent = field.name;
|
|
const value = document.createElement("code");
|
|
value.textContent = field.value;
|
|
row.append(kind, name, value);
|
|
fragment.append(row);
|
|
}
|
|
elements.sceneDataPreview.append(fragment);
|
|
}
|
|
|
|
function isDryRunMode(mode = state.playout.mode) {
|
|
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
|
|
}
|
|
|
|
function isLiveMode(mode = state.playout.mode) {
|
|
return /live|program/.test(normalizePlayoutMode(mode));
|
|
}
|
|
|
|
function isTestMode(mode = state.playout.mode) {
|
|
return /test/.test(normalizePlayoutMode(mode));
|
|
}
|
|
|
|
function playoutDisplayState() {
|
|
if (isDryRunMode()) return "DRY RUN";
|
|
if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR";
|
|
return 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 workflowEntryFromItem(item) {
|
|
const selection = item?.selection && typeof item.selection === "object"
|
|
? item.selection
|
|
: {
|
|
groupCode: String(item?.groupCode || item?.market || ""),
|
|
subject: String(item?.subject || item?.title || ""),
|
|
graphicType: String(item?.graphicType || item?.detail || ""),
|
|
subtype: String(item?.subtype || ""),
|
|
dataCode: String(item?.dataCode || "")
|
|
};
|
|
return {
|
|
id: String(item.id),
|
|
code: String(item.code),
|
|
enabled: item.enabled !== false,
|
|
fadeDuration: Number.isInteger(Number(item.fadeDuration))
|
|
? Number(item.fadeDuration)
|
|
: DEFAULT_FADE_DURATION,
|
|
selection: {
|
|
groupCode: String(selection.groupCode || ""),
|
|
subject: String(selection.subject || ""),
|
|
graphicType: String(selection.graphicType || ""),
|
|
subtype: String(selection.subtype || ""),
|
|
dataCode: String(selection.dataCode || "")
|
|
}
|
|
};
|
|
}
|
|
|
|
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.nativeOutcomeUnknown ||
|
|
state.playout.sessionQuarantined ||
|
|
state.playout.error?.outcomeUnknown === true;
|
|
const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable;
|
|
const mutationCommandReady = commandReady && !state.playout.refreshFaulted;
|
|
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
|
|
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
|
|
const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode);
|
|
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
|
|
|
|
elements.playoutState.textContent = displayState;
|
|
elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error");
|
|
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
|
|
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
|
|
if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air");
|
|
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"));
|
|
const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState);
|
|
const connectionLabels = {
|
|
"disabled": "Disabled",
|
|
"dry-run-ready": "DryRunReady",
|
|
"disconnected": "Disconnected",
|
|
"connecting": "Connecting",
|
|
"connected": "Connected",
|
|
"reconnecting": "Reconnecting",
|
|
"disconnecting": "Disconnecting",
|
|
"faulted": "Faulted",
|
|
"outcome-unknown": "OutcomeUnknown",
|
|
"disposed": "Disposed",
|
|
"unavailable": "Unavailable"
|
|
};
|
|
const lastKtapLabels = {
|
|
"attempted": "마지막 KTAP 결과 미확인",
|
|
"accepted-unconfirmed": "마지막 KTAP 수락 · OnHello 미확인",
|
|
"failed": "마지막 KTAP 실패"
|
|
};
|
|
const currentConnectionLabel = connectionLabels[state.playout.connectionState] || "Unavailable";
|
|
elements.playoutConnectionState.textContent = dryRun
|
|
? "KTAP 미호출"
|
|
: (state.playout.connected && state.playout.lastKtapConnectState === "accepted-unconfirmed"
|
|
? "KTAP 수락 · OnHello 미확인"
|
|
: `${currentConnectionLabel}${lastKtapLabels[state.playout.lastKtapConnectState]
|
|
? ` · ${lastKtapLabels[state.playout.lastKtapConnectState]}`
|
|
: ""}`);
|
|
setStatusDot(elements.playoutConnectionDot, state.playout.connected
|
|
? (state.playout.ktapHelloObserved === true ? "healthy" : "warn")
|
|
: (transitionalConnection ? "warn" : (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 })}`
|
|
: "";
|
|
const pageLabel = state.playout.pageCount > 0
|
|
? ` · 페이지 ${state.playout.pageIndex + 1}/${state.playout.pageCount}`
|
|
: "";
|
|
elements.playoutStatusMessage.textContent = pending
|
|
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
|
|
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${pageLabel}${changedLabel}`;
|
|
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
|
|
elements.playoutStatusMessage.classList.toggle(
|
|
"error",
|
|
Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown || state.playout.refreshFaulted);
|
|
elements.playoutSceneCode.textContent = state.playout.onAirCode || state.playout.preparedCode || "—";
|
|
elements.playoutEntryId.textContent = state.playout.currentEntryId || "—";
|
|
elements.playoutBuilderKey.textContent = state.playout.builderKey || "—";
|
|
elements.playoutPageNumber.textContent = state.playout.pageCount > 0
|
|
? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
|
|
: "—";
|
|
elements.playoutPageSize.textContent = state.playout.pageSize > 0
|
|
? String(state.playout.pageSize)
|
|
: "—";
|
|
elements.playoutItemCount.textContent = String(state.playout.itemCount);
|
|
elements.playoutCurrentRows.textContent = String(state.playout.currentPageItemCount);
|
|
elements.playoutLastPage.textContent = state.playout.isLastPage ? "YES" : "NO";
|
|
elements.playoutNextKind.textContent = state.playout.nextKind.replaceAll("-", " ").toUpperCase();
|
|
const refreshNextMs = parseChangedAt(state.playout.refreshNextAt);
|
|
elements.playoutRefreshState.textContent = state.playout.refreshFaulted
|
|
? "FAULT"
|
|
: (state.playout.refreshActive
|
|
? `ACTIVE · ${refreshNextMs ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false }) : "WAIT"}`
|
|
: "STOPPED");
|
|
renderSceneDataPreview();
|
|
|
|
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
|
|
if (outcomeUnknown) {
|
|
elements.playoutSummary.classList.add("error");
|
|
elements.playoutSummaryBadge.textContent = "UNKNOWN";
|
|
elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요";
|
|
} else if (state.playout.refreshFaulted) {
|
|
elements.playoutSummary.classList.add("error");
|
|
elements.playoutSummaryBadge.textContent = "REFRESH FAULT";
|
|
elements.playoutSummaryDetail.textContent = "자동 갱신 중단 · TAKE OUT 후 확인 필요";
|
|
} else if (dryRun) {
|
|
elements.playoutSummary.classList.add("dry-run");
|
|
elements.playoutSummaryBadge.textContent = "DRY RUN";
|
|
elements.playoutSummaryDetail.textContent = bridgeReady ? "KTAP 미호출 · 모니터 기록 없음(정상)" : "브라우저 미리보기 전용";
|
|
} 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 = state.playout.lastKtapConnectState === "accepted-unconfirmed"
|
|
? "KTAP 수락 · OnHello/[R]/[S] 확인 필요"
|
|
: (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 = !mutationCommandReady || !currentCue;
|
|
elements.takeInButton.disabled = !mutationCommandReady || !prepared || !takeInSafe;
|
|
elements.nextButton.disabled = !mutationCommandReady || !onAir || state.playlist.length === 0 ||
|
|
state.playout.nextKind === "end-of-playlist";
|
|
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
|
|
const playlistLocked = isPlaylistSnapshotLocked();
|
|
elements.selectAll.disabled = playlistLocked;
|
|
elements.moveUpButton.disabled = playlistLocked;
|
|
elements.moveDownButton.disabled = playlistLocked;
|
|
elements.deleteButton.disabled = playlistLocked;
|
|
elements.loadButton.disabled = playlistLocked;
|
|
renderCatalog();
|
|
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) {
|
|
if (!playoutSafety.canClearCommandError(
|
|
state.playout.error,
|
|
state.playout.sessionQuarantined || state.playout.nativeOutcomeUnknown)) {
|
|
if (render) showToast("결과 미확인 잠금은 앱을 다시 시작하기 전까지 해제할 수 없습니다.");
|
|
return;
|
|
}
|
|
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 };
|
|
if (outcomeUnknown || code === "WEB_TIMEOUT") state.playout.sessionQuarantined = true;
|
|
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") {
|
|
const cue = findCueByCode(state.playout.onAirCode) || current;
|
|
return cue ? { cue: cueFromItem(cue), targetIndex: state.currentIndex } : null;
|
|
}
|
|
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 blockReason = playoutSafety.commandBlockReason({
|
|
pending: state.playout.pending,
|
|
outcomeUnknown: state.playout.nativeOutcomeUnknown ||
|
|
state.playout.sessionQuarantined ||
|
|
state.playout.error?.outcomeUnknown === true,
|
|
commandAvailable: state.playout.commandAvailable,
|
|
refreshFaulted: state.playout.refreshFaulted,
|
|
engineState: state.playout.engineState,
|
|
preparedCode: state.playout.preparedCode,
|
|
onAirCode: state.playout.onAirCode
|
|
}, command);
|
|
if (blockReason) {
|
|
const message = {
|
|
"outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
|
|
"unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.",
|
|
"refresh-fault": "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
|
|
"next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.",
|
|
"take-in-requires-prepare": "먼저 PREPARE를 완료하세요.",
|
|
"nothing-to-take-out": "종료할 송출 장면이 없습니다."
|
|
}[blockReason] || "다른 송출 명령을 처리하고 있습니다.";
|
|
showToast(message);
|
|
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 (command === "prepare") {
|
|
payload.playlist = state.playlist.map(workflowEntryFromItem);
|
|
payload.selectedIndex = state.currentIndex;
|
|
}
|
|
postNative("playout-command", payload);
|
|
|
|
pending.timeoutId = setTimeout(() => {
|
|
if (state.playout.pending?.requestId !== requestId) return;
|
|
state.playout.pending = null;
|
|
postNative("playout-timeout-quarantine", { requestId, command });
|
|
showPlayoutError({
|
|
code: "WEB_TIMEOUT",
|
|
message: "송출 엔진 응답 시간이 초과되었습니다.",
|
|
retryable: false,
|
|
outcomeUnknown: true
|
|
});
|
|
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
|
|
requestPlayoutStatus();
|
|
}, state.playout.responseTimeoutMs);
|
|
}
|
|
|
|
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 previousCueIndex = state.currentIndex;
|
|
const previousSnapshotLocked = isPlaylistSnapshotLocked();
|
|
|
|
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.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState);
|
|
state.playout.processDetected = payload.processDetected === true;
|
|
state.playout.connected = payload.connected === true;
|
|
state.playout.lastKtapConnectState = ["not-attempted", "attempted", "accepted-unconfirmed", "failed"]
|
|
.includes(payload.lastKtapConnectState) ? payload.lastKtapConnectState : "not-attempted";
|
|
state.playout.ktapConnectAttempted = payload.ktapConnectAttempted === true;
|
|
state.playout.ktapConnectAccepted = payload.ktapConnectAccepted === true
|
|
? true
|
|
: (payload.ktapConnectAccepted === false ? false : null);
|
|
state.playout.ktapHelloObserved = payload.ktapHelloObserved === true
|
|
? true
|
|
: (payload.ktapHelloObserved === false ? false : null);
|
|
state.playout.networkMonitoringRecordExpected = payload.networkMonitoringRecordExpected === true
|
|
? true
|
|
: (payload.networkMonitoringRecordExpected === false ? false : null);
|
|
state.playout.networkMonitoringCheckRequired = payload.networkMonitoringCheckRequired === true;
|
|
state.playout.commandAvailable = payload.commandAvailable === true;
|
|
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
|
|
state.playout.browserCorrelationQuarantined =
|
|
payload.browserCorrelationQuarantined === true;
|
|
state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true ||
|
|
state.playout.browserCorrelationQuarantined ||
|
|
state.playout.connectionState === "outcome-unknown";
|
|
state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds))
|
|
? Number(payload.operationTimeoutMilliseconds)
|
|
: state.playout.operationTimeoutMs;
|
|
state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds);
|
|
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.currentCueIndex = Number.isInteger(payload.currentCueIndex) ? payload.currentCueIndex : -1;
|
|
state.playout.currentEntryId = typeof payload.currentEntryId === "string"
|
|
? payload.currentEntryId
|
|
: null;
|
|
state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
|
|
? String(payload.builderKey)
|
|
: null;
|
|
state.playout.pageIndex = Number.isInteger(payload.pageIndex) && payload.pageIndex >= 0 ? payload.pageIndex : 0;
|
|
state.playout.pageCount = Number.isInteger(payload.pageCount) && payload.pageCount >= 0 ? payload.pageCount : 0;
|
|
state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : 0;
|
|
state.playout.itemCount = Number.isInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : 0;
|
|
state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount) &&
|
|
payload.currentPageItemCount >= 0 ? payload.currentPageItemCount : 0;
|
|
state.playout.isLastPage = payload.isLastPage !== false;
|
|
state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
|
|
.includes(payload.nextKind) ? payload.nextKind : "none";
|
|
state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
|
|
applyRefreshState(payload);
|
|
const authoritativeIndex = state.playout.currentEntryId
|
|
? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
|
|
: state.playout.currentCueIndex;
|
|
if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
|
|
state.currentIndex = authoritativeIndex;
|
|
}
|
|
state.playout.changedAt = payload.changedAt || null;
|
|
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
|
|
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
|
|
|
|
if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) {
|
|
state.playout.error = {
|
|
code: "OUTCOME_UNKNOWN",
|
|
message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
|
|
retryable: false,
|
|
outcomeUnknown: true
|
|
};
|
|
}
|
|
|
|
if (state.currentIndex !== previousCueIndex ||
|
|
isPlaylistSnapshotLocked() !== previousSnapshotLocked) {
|
|
renderPlaylist();
|
|
updatePreview();
|
|
} else {
|
|
renderPlayout();
|
|
}
|
|
const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.lastKtapConnectState, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].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 (!playoutSafety.matchesPendingCommand(pending, payload)) 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;
|
|
state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex)
|
|
? payload.currentCueIndex
|
|
: state.playout.currentCueIndex;
|
|
state.playout.currentEntryId = typeof payload.currentEntryId === "string"
|
|
? payload.currentEntryId
|
|
: state.playout.currentEntryId;
|
|
state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
|
|
? String(payload.builderKey)
|
|
: state.playout.builderKey;
|
|
state.playout.pageIndex = Number.isInteger(payload.pageIndex) ? payload.pageIndex : state.playout.pageIndex;
|
|
state.playout.pageCount = Number.isInteger(payload.pageCount) ? payload.pageCount : state.playout.pageCount;
|
|
state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : state.playout.pageSize;
|
|
state.playout.itemCount = Number.isInteger(payload.itemCount) ? payload.itemCount : state.playout.itemCount;
|
|
state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount)
|
|
? payload.currentPageItemCount
|
|
: state.playout.currentPageItemCount;
|
|
state.playout.isLastPage = payload.isLastPage !== false;
|
|
state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
|
|
.includes(payload.nextKind) ? payload.nextKind : state.playout.nextKind;
|
|
state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
|
|
applyRefreshState(payload);
|
|
|
|
if (pending.command === "prepare") {
|
|
state.playout.preparedCue = pending.cue;
|
|
} else if (!state.playout.preparedCode) {
|
|
state.playout.preparedCue = null;
|
|
}
|
|
const authoritativeIndex = state.playout.currentEntryId
|
|
? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
|
|
: state.playout.currentCueIndex;
|
|
if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
|
|
state.currentIndex = authoritativeIndex;
|
|
}
|
|
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;
|
|
if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true;
|
|
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) {
|
|
const item = document.createElement("li");
|
|
const time = document.createElement("time");
|
|
time.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
|
|
item.append(time, document.createTextNode(message));
|
|
elements.eventLog.prepend(item);
|
|
while (elements.eventLog.children.length > 30) elements.eventLog.lastElementChild?.remove();
|
|
}
|
|
|
|
let toastTimer;
|
|
function showToast(message) {
|
|
elements.toast.textContent = message;
|
|
elements.toast.classList.add("show");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
|
|
}
|
|
|
|
function postNative(type, payload = {}) {
|
|
nativeBridge?.postMessage({ type, payload });
|
|
}
|
|
|
|
function normalizeSourceName(source) {
|
|
const value = String(source || "").toLocaleLowerCase("en-US");
|
|
if (value === "oracle") return "oracle";
|
|
if (value === "mariadb" || value === "maria_db" || value === "maria-db") return "mariaDb";
|
|
return String(source || "unknown");
|
|
}
|
|
|
|
function normalizedDatabaseSources(sources) {
|
|
const received = new Map((Array.isArray(sources) ? sources : []).map(source => [normalizeSourceName(source?.source), source]));
|
|
return ["oracle", "mariaDb"].map(name => {
|
|
const source = received.get(name);
|
|
return {
|
|
source: name,
|
|
configured: Boolean(source?.configured),
|
|
healthy: Boolean(source?.healthy),
|
|
message: typeof source?.message === "string" ? source.message : "",
|
|
checkedAt: source?.checkedAt || null,
|
|
latencyMs: source?.latencyMs !== null && source?.latencyMs !== undefined && Number.isFinite(Number(source.latencyMs))
|
|
? Number(source.latencyMs)
|
|
: null
|
|
};
|
|
});
|
|
}
|
|
|
|
function sourceHealthClass(source) {
|
|
if (state.database.loading && !state.database.sources.length) return "pending";
|
|
if (!source.configured) return "unconfigured";
|
|
return source.healthy ? "healthy" : "error";
|
|
}
|
|
|
|
function sourceHealthText(source) {
|
|
if (state.database.loading && !state.database.sources.length) return "확인 중";
|
|
if (!source.configured) return "미설정";
|
|
if (!source.healthy) return "연결 오류";
|
|
return source.latencyMs === null ? "정상" : `정상 · ${Math.round(source.latencyMs)}ms`;
|
|
}
|
|
|
|
function renderDatabaseSources() {
|
|
elements.databaseSources.replaceChildren();
|
|
const sources = state.database.sources.length
|
|
? state.database.sources
|
|
: normalizedDatabaseSources([]);
|
|
|
|
for (const source of sources) {
|
|
const chip = document.createElement("div");
|
|
const healthClass = sourceHealthClass(source);
|
|
chip.className = `database-source ${healthClass}`;
|
|
if (source.message) chip.title = source.message;
|
|
|
|
const dot = document.createElement("span");
|
|
dot.className = "source-dot";
|
|
const label = document.createElement("strong");
|
|
label.textContent = sourceLabels[source.source] || source.source;
|
|
const status = document.createElement("small");
|
|
status.textContent = sourceHealthText(source);
|
|
chip.append(dot, label, status);
|
|
elements.databaseSources.append(chip);
|
|
}
|
|
}
|
|
|
|
function renderDatabaseStatus() {
|
|
const sources = state.database.sources;
|
|
const configured = sources.filter(source => source.configured);
|
|
const healthy = configured.filter(source => source.healthy);
|
|
let stateClass = "pending";
|
|
let stateText = "확인 중";
|
|
let detail = "연결 상태 확인 중";
|
|
let badge = "CHECKING";
|
|
|
|
if (!state.database.loading || sources.length) {
|
|
if (!configured.length) {
|
|
stateClass = "unconfigured";
|
|
stateText = "미설정";
|
|
detail = "Oracle 미설정 · MariaDB 미설정";
|
|
badge = "SETUP";
|
|
} else if (healthy.length === sources.length && sources.length === 2) {
|
|
stateClass = "healthy";
|
|
stateText = "정상 (2/2)";
|
|
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
|
|
badge = "CONNECTED";
|
|
} else if (configured.some(source => !source.healthy)) {
|
|
stateClass = "error";
|
|
stateText = `오류 (${healthy.length}/${configured.length})`;
|
|
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
|
|
badge = "ERROR";
|
|
} else {
|
|
stateClass = "partial";
|
|
stateText = `일부 연결 (${healthy.length}/2)`;
|
|
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
|
|
badge = "PARTIAL";
|
|
}
|
|
}
|
|
|
|
elements.databaseState.textContent = stateText;
|
|
elements.databaseStateDot.className = `status-dot ${stateClass}`;
|
|
elements.databaseSummary.className = `database-summary ${stateClass}`;
|
|
elements.databaseSummaryIcon.className = `metric-icon database-icon ${stateClass}`;
|
|
elements.databaseSummaryDetail.textContent = detail;
|
|
elements.databaseSummaryBadge.textContent = badge;
|
|
renderDatabaseSources();
|
|
}
|
|
|
|
function requestDatabaseStatus() {
|
|
if (!nativeBridge) return;
|
|
state.database.loading = true;
|
|
renderDatabaseStatus();
|
|
postNative("request-database-status", { requestId: createId() });
|
|
}
|
|
|
|
function handleDatabaseStatus(payload) {
|
|
const sequence = payload?.sequence;
|
|
if (!playoutSafety.isNewerDatabaseStatusSequence(
|
|
state.database.latestSequence,
|
|
sequence)) return;
|
|
|
|
state.database.latestSequence = sequence;
|
|
state.database.sources = normalizedDatabaseSources(payload?.sources);
|
|
state.database.loading = false;
|
|
renderDatabaseStatus();
|
|
|
|
const signature = state.database.sources.map(source => `${source.source}:${source.configured}:${source.healthy}`).join("|");
|
|
if (signature !== state.database.lastSignature) {
|
|
const message = state.database.sources
|
|
.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`)
|
|
.join(" · ");
|
|
addLog(`DB 상태 · ${message}`);
|
|
state.database.lastSignature = signature;
|
|
}
|
|
}
|
|
|
|
function clearLiveDataTimeout() {
|
|
if (state.liveData.timeoutId !== null) clearTimeout(state.liveData.timeoutId);
|
|
state.liveData.timeoutId = null;
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return new Intl.DateTimeFormat("ko-KR", {
|
|
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
|
|
}).format(date);
|
|
}
|
|
|
|
function normalizeColumns(table) {
|
|
let columns = Array.isArray(table?.columns) ? table.columns : [];
|
|
const firstRow = Array.isArray(table?.rows) ? table.rows[0] : null;
|
|
if (!columns.length && firstRow && !Array.isArray(firstRow) && typeof firstRow === "object") {
|
|
columns = Object.keys(firstRow);
|
|
}
|
|
if (!columns.length && Array.isArray(firstRow)) {
|
|
columns = firstRow.map((_, index) => `열 ${index + 1}`);
|
|
}
|
|
return columns.map((column, index) => {
|
|
if (typeof column === "string") return { key: column, label: column };
|
|
const key = column?.key ?? column?.name ?? column?.field ?? column?.dataPropertyName ?? `column-${index}`;
|
|
const label = column?.label ?? column?.displayName ?? column?.title ?? column?.name ?? key;
|
|
return { key: String(key), label: String(label) };
|
|
});
|
|
}
|
|
|
|
function cellValue(row, column, index) {
|
|
if (Array.isArray(row)) return row[index];
|
|
if (row && typeof row === "object") return row[column.key];
|
|
return index === 0 ? row : null;
|
|
}
|
|
|
|
function formatCellValue(value) {
|
|
if (value === null || value === undefined || value === "") return "—";
|
|
if (typeof value === "number") return value.toLocaleString("ko-KR");
|
|
if (typeof value === "boolean") return value ? "예" : "아니요";
|
|
if (typeof value === "object") {
|
|
try { return JSON.stringify(value); } catch { return String(value); }
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function liveRowValues(row, columns) {
|
|
const values = new Map();
|
|
columns.forEach((column, index) => {
|
|
values.set(String(column.key).toLocaleUpperCase("en-US"), cellValue(row, column, index));
|
|
});
|
|
return values;
|
|
}
|
|
|
|
function firstLiveValue(values, keys) {
|
|
for (const key of keys) {
|
|
const value = values.get(key);
|
|
if (value !== null && value !== undefined && String(value).trim()) return String(value).trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function liveSelectionGroup(table, item) {
|
|
const name = String(table?.name || "").toLocaleUpperCase("ko-KR");
|
|
let group;
|
|
if (name.includes("NXT") && state.activeMarket === "kospi") group = "NXT_KOSPI";
|
|
else if (name.includes("NXT") && state.activeMarket === "kosdaq") group = "NXT_KOSDAQ";
|
|
else if (state.activeMarket === "kospi") group = "KOSPI_STOCK";
|
|
else if (state.activeMarket === "kosdaq") group = "KOSDAQ_STOCK";
|
|
else if (state.activeMarket === "overseas") group = name.includes("종목") ? "FOREIGN_STOCK" : "FOREIGN_INDEX";
|
|
else if (name.includes("코스닥")) group = "KOSDAQ_INDEX";
|
|
else if (name.includes("200")) group = "KOSPI200_INDEX";
|
|
else if (name.includes("KRX100")) group = "KRX100_INDEX";
|
|
else group = "KOSPI_INDEX";
|
|
|
|
if (["s5037", "s5079", "s5080", "s8003"].includes(item?.builderKey)) {
|
|
if (group === "KOSPI_STOCK") return "KOSPI";
|
|
if (group === "KOSDAQ_STOCK") return "KOSDAQ";
|
|
}
|
|
return group;
|
|
}
|
|
|
|
function applyLiveRowToCurrentCut(table, row, columns) {
|
|
const item = state.playlist[state.currentIndex];
|
|
if (!item) {
|
|
showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
|
|
return;
|
|
}
|
|
|
|
const values = liveRowValues(row, columns);
|
|
const subject = firstLiveValue(values, [
|
|
"F_STOCK_WANNAME", "F_STOCK_NAME", "STOCK_NAME", "F_KNAM",
|
|
"F_INPUT_NAME", "NAME", "WANNAME"
|
|
]);
|
|
const dataCode = firstLiveValue(values, [
|
|
"F_STOCK_CODE", "STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
|
|
]);
|
|
if (!subject && !dataCode) {
|
|
showToast("이 행에는 장면 조회에 사용할 종목명이나 코드가 없습니다.");
|
|
return;
|
|
}
|
|
|
|
const previousSelection = item.selection || {};
|
|
item.selection = {
|
|
groupCode: liveSelectionGroup(table, item),
|
|
subject: subject || dataCode,
|
|
graphicType: String(previousSelection.graphicType || item.graphicType || ""),
|
|
subtype: String(previousSelection.subtype || item.subtype || ""),
|
|
dataCode
|
|
};
|
|
item.subject = item.selection.subject;
|
|
item.dataCode = dataCode;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`);
|
|
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
|
|
}
|
|
|
|
function renderLiveTables() {
|
|
elements.liveDataTables.replaceChildren();
|
|
for (const table of state.liveData.tables) {
|
|
const section = document.createElement("section");
|
|
section.className = "live-table-card";
|
|
|
|
const header = document.createElement("header");
|
|
const heading = document.createElement("div");
|
|
const name = document.createElement("h4");
|
|
name.textContent = table?.name || "시장 데이터";
|
|
const meta = document.createElement("small");
|
|
const rows = Array.isArray(table?.rows) ? table.rows : [];
|
|
const total = Number.isFinite(Number(table?.totalRowCount)) ? Number(table.totalRowCount) : rows.length;
|
|
meta.textContent = `전체 ${total.toLocaleString("ko-KR")}건 · 표시 ${rows.length.toLocaleString("ko-KR")}건`;
|
|
heading.append(name, meta);
|
|
|
|
const source = document.createElement("span");
|
|
source.className = "live-source-badge";
|
|
const sourceName = normalizeSourceName(table?.source);
|
|
source.textContent = sourceLabels[sourceName] || table?.source || "DB";
|
|
header.append(heading, source);
|
|
section.append(header);
|
|
|
|
if (!rows.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "live-table-empty";
|
|
empty.textContent = "조회된 행이 없습니다.";
|
|
section.append(empty);
|
|
elements.liveDataTables.append(section);
|
|
continue;
|
|
}
|
|
|
|
const columns = normalizeColumns(table);
|
|
const scroll = document.createElement("div");
|
|
scroll.className = "live-table-scroll";
|
|
const grid = document.createElement("table");
|
|
grid.className = "live-table";
|
|
const head = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
for (const column of columns) {
|
|
const th = document.createElement("th");
|
|
th.textContent = column.label;
|
|
headRow.append(th);
|
|
}
|
|
head.append(headRow);
|
|
|
|
const body = document.createElement("tbody");
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
tr.className = "live-data-selectable";
|
|
tr.title = "두 번 클릭하면 현재 플레이리스트 장면의 DB 선택값으로 적용합니다.";
|
|
tr.addEventListener("dblclick", () => applyLiveRowToCurrentCut(table, row, columns));
|
|
columns.forEach((column, index) => {
|
|
const td = document.createElement("td");
|
|
td.textContent = formatCellValue(cellValue(row, column, index));
|
|
tr.append(td);
|
|
});
|
|
body.append(tr);
|
|
}
|
|
grid.append(head, body);
|
|
scroll.append(grid);
|
|
section.append(scroll);
|
|
elements.liveDataTables.append(section);
|
|
}
|
|
}
|
|
|
|
function renderLiveData() {
|
|
const visible = liveDataViews.has(state.activeMarket);
|
|
elements.liveDataPanel.hidden = !visible;
|
|
elements.catalogModeBadge.textContent = visible ? "DB LIVE" : "Web UI";
|
|
elements.catalogModeBadge.classList.toggle("live", visible);
|
|
if (!visible) return;
|
|
|
|
elements.liveDataTitle.textContent = `${marketTitles[state.activeMarket]} · DB 조회`;
|
|
elements.liveDataState.replaceChildren();
|
|
elements.liveDataState.className = `live-data-state ${state.liveData.status}`;
|
|
|
|
if (state.liveData.status === "loading") {
|
|
const spinner = document.createElement("span");
|
|
spinner.className = "live-spinner";
|
|
const copy = document.createElement("div");
|
|
const strong = document.createElement("strong");
|
|
const small = document.createElement("small");
|
|
strong.textContent = "데이터베이스에서 조회 중입니다.";
|
|
small.textContent = "연결 상태와 시장 데이터를 확인하고 있습니다.";
|
|
copy.append(strong, small);
|
|
elements.liveDataState.append(spinner, copy);
|
|
} else if (state.liveData.status === "error") {
|
|
const icon = document.createElement("span");
|
|
icon.className = "live-error-icon";
|
|
icon.textContent = "!";
|
|
const copy = document.createElement("div");
|
|
const strong = document.createElement("strong");
|
|
const small = document.createElement("small");
|
|
strong.textContent = "시장 데이터를 조회하지 못했습니다.";
|
|
small.textContent = state.liveData.error || "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
|
|
copy.append(strong, small);
|
|
elements.liveDataState.append(icon, copy);
|
|
} else if (state.liveData.status === "ready") {
|
|
const totalRows = state.liveData.tables.reduce((sum, table) => {
|
|
const count = Number(table?.totalRowCount);
|
|
return sum + (Number.isFinite(count) ? count : (Array.isArray(table?.rows) ? table.rows.length : 0));
|
|
}, 0);
|
|
const strong = document.createElement("strong");
|
|
const small = document.createElement("small");
|
|
strong.textContent = state.liveData.tables.length
|
|
? `${state.liveData.tables.length}개 데이터셋 · 전체 ${totalRows.toLocaleString("ko-KR")}건`
|
|
: "조회 결과가 없습니다.";
|
|
small.textContent = state.liveData.retrievedAt ? `조회 시각 ${formatDateTime(state.liveData.retrievedAt)}` : "조회 완료";
|
|
elements.liveDataState.append(strong, small);
|
|
}
|
|
|
|
renderDatabaseSources();
|
|
renderLiveTables();
|
|
}
|
|
|
|
function requestMarketData(view = state.activeMarket) {
|
|
clearLiveDataTimeout();
|
|
if (!liveDataViews.has(view)) {
|
|
state.liveData.requestId = null;
|
|
state.liveData.view = null;
|
|
state.liveData.status = "idle";
|
|
state.liveData.tables = [];
|
|
renderLiveData();
|
|
return;
|
|
}
|
|
|
|
const requestId = createId();
|
|
state.liveData.requestId = requestId;
|
|
state.liveData.view = view;
|
|
state.liveData.status = "loading";
|
|
state.liveData.tables = [];
|
|
state.liveData.retrievedAt = null;
|
|
state.liveData.error = "";
|
|
renderLiveData();
|
|
|
|
if (!nativeBridge) {
|
|
state.liveData.status = "error";
|
|
state.liveData.error = "브라우저 미리보기에서는 실제 DB 데이터를 조회할 수 없습니다.";
|
|
renderLiveData();
|
|
return;
|
|
}
|
|
|
|
postNative("request-market-data", { requestId, view, maximumRowsPerTable: 250 });
|
|
state.liveData.timeoutId = setTimeout(() => {
|
|
if (state.liveData.requestId !== requestId || state.liveData.status !== "loading") return;
|
|
state.liveData.status = "error";
|
|
state.liveData.error = "DB 응답 시간이 초과되었습니다. 연결 상태를 확인하고 다시 조회하세요.";
|
|
renderLiveData();
|
|
addLog(`${marketTitles[view]} DB 조회 시간 초과`);
|
|
}, 30000);
|
|
}
|
|
|
|
function handleMarketData(payload) {
|
|
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
|
|
clearLiveDataTimeout();
|
|
state.liveData.status = "ready";
|
|
state.liveData.tables = Array.isArray(payload.tables) ? payload.tables : [];
|
|
state.liveData.retrievedAt = payload.retrievedAt || new Date().toISOString();
|
|
state.liveData.error = "";
|
|
renderLiveData();
|
|
const shownRows = state.liveData.tables.reduce((sum, table) => sum + (Array.isArray(table?.rows) ? table.rows.length : 0), 0);
|
|
addLog(`${marketTitles[payload.view]} DB 조회 완료 · ${shownRows.toLocaleString("ko-KR")}행 표시`);
|
|
}
|
|
|
|
function handleMarketDataError(payload) {
|
|
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
|
|
clearLiveDataTimeout();
|
|
state.liveData.status = "error";
|
|
state.liveData.tables = [];
|
|
state.liveData.error = typeof payload.message === "string" && payload.message.trim()
|
|
? payload.message.trim()
|
|
: "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
|
|
renderLiveData();
|
|
addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`);
|
|
}
|
|
|
|
function handleNativeMessage(event) {
|
|
let message = event.data;
|
|
if (typeof message === "string") {
|
|
try { message = JSON.parse(message); } catch { return; }
|
|
}
|
|
if (!message || typeof message.type !== "string") return;
|
|
|
|
switch (message.type) {
|
|
case "app-info": {
|
|
const info = message.payload || {};
|
|
elements.bridgeState.textContent = "연결됨";
|
|
elements.runtimeLabel.textContent = `${info.framework || ".NET 8"} · ${info.architecture || "x64"}`;
|
|
elements.packageLabel.textContent = `${info.package || "MSIX"}${info.version ? ` · v${info.version}` : ""}`;
|
|
addLog(`Native Bridge 연결${info.windowsAppSdk ? ` · Windows App SDK ${info.windowsAppSdk}` : ""}`);
|
|
break;
|
|
}
|
|
case "database-status":
|
|
handleDatabaseStatus(message.payload);
|
|
break;
|
|
case "market-data":
|
|
handleMarketData(message.payload);
|
|
break;
|
|
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;
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
document.querySelector("#marketNav").addEventListener("click", event => {
|
|
const button = event.target.closest("button[data-market]");
|
|
if (!button) return;
|
|
document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button));
|
|
state.activeMarket = button.dataset.market;
|
|
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
|
|
renderCatalog();
|
|
requestMarketData(state.activeMarket);
|
|
if (liveDataViews.has(state.activeMarket)) requestDatabaseStatus();
|
|
});
|
|
|
|
elements.catalogTabs.addEventListener("click", event => {
|
|
const button = event.target.closest("button[data-category]");
|
|
if (!button) return;
|
|
elements.catalogTabs.querySelectorAll("button").forEach(item => item.classList.toggle("active", item === button));
|
|
state.activeCategory = button.dataset.category;
|
|
renderCatalog();
|
|
});
|
|
|
|
elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); });
|
|
document.querySelector("#selectAllButton").addEventListener("click", toggleSelectAll);
|
|
document.querySelector("#moveUpButton").addEventListener("click", () => moveSelected(-1));
|
|
document.querySelector("#moveDownButton").addEventListener("click", () => moveSelected(1));
|
|
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
|
|
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
|
|
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
|
|
elements.sceneSelectionForm.addEventListener("submit", applySceneSelection);
|
|
elements.sceneCutAlias.addEventListener("change", applyCutAliasPreset);
|
|
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", () => {
|
|
requestDatabaseStatus();
|
|
requestMarketData(state.activeMarket);
|
|
});
|
|
|
|
document.addEventListener("keydown", event => {
|
|
if (event.ctrlKey && event.key.toLowerCase() === "k") {
|
|
event.preventDefault(); elements.catalogSearch.focus(); return;
|
|
}
|
|
if (event.target instanceof HTMLInputElement ||
|
|
event.target instanceof HTMLTextAreaElement ||
|
|
event.target instanceof HTMLSelectElement) return;
|
|
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"); }
|
|
});
|
|
}
|
|
|
|
function initialize() {
|
|
bindEvents();
|
|
renderCatalog();
|
|
renderPlaylist();
|
|
updatePreview();
|
|
renderDatabaseStatus();
|
|
renderLiveData();
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
addLog("WebView 운영 화면 시작");
|
|
|
|
if (nativeBridge) {
|
|
nativeBridge.addEventListener("message", handleNativeMessage);
|
|
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 없이 미리보기 전용 DRY RUN으로 실행");
|
|
}
|
|
}
|
|
|
|
initialize();
|
|
})();
|