(() => { "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(); })();