feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -3,24 +3,111 @@
const playoutSafety = globalThis.MbnPlayoutSafety;
if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
const DEFAULT_FADE_DURATION = 6;
const catalog = [
{ code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" },
{ code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" },
{ code: "5016", title: "3열판 · 주요 지수", detail: "국내외 지수 3열 구성", category: "plate", market: "index" },
{ code: "5023", title: "매매동향 · 지수별", detail: "코스피 / 코스닥", category: "market", market: "index" },
{ code: "5024", title: "매매동향 · 막대그래프", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
{ code: "5026", title: "비교분석 · 캔들형", detail: "두 종목 비교", category: "chart", market: "compare" },
{ code: "5074", title: "5단표", detail: "상승률 / 하락률 / 거래량", category: "plate", market: "kosdaq" },
{ code: "5083", title: "선그래프 · 주체별", detail: "개인 / 외국인 / 기관", category: "chart", market: "kospi" },
{ code: "5086", title: "수익률 그래프", detail: "기간별 수익률", category: "chart", market: "compare" },
{ code: "6001", title: "해외증시", detail: "미국 / 유럽 / 아시아", category: "market", market: "overseas" },
{ code: "8035", title: "캔들 그래프 · 5분", detail: "지수 / 종목 5분봉", category: "chart", market: "index" },
{ code: "8086", title: "유가 · 금값", detail: "WTI / 브렌트 / 국제금", category: "market", market: "exchange" },
{ code: "THEME", title: "테마 종목", detail: "테마별 5·6·12 종목", category: "plate", market: "theme" },
{ code: "EXPERT", title: "전문가 추천", detail: "추천 종목 그래", category: "plate", market: "expert" },
{ code: "HALT", title: "거래 정지", detail: "정지 종목 검색", category: "market", market: "halted" }
{ 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: "환율 그래픽",
@@ -54,12 +141,31 @@
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,
@@ -95,6 +201,11 @@
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"),
@@ -107,8 +218,28 @@
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"),
@@ -121,6 +252,24 @@
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
@@ -167,7 +316,8 @@
const add = document.createElement("button");
add.className = "add-cut";
add.type = "button";
add.title = "플레이리스트에 추가";
add.disabled = item.reachable === false || isPlaylistSnapshotLocked();
add.title = item.reachable === false ? "원본 MainForm에서 도달하지 않는 장면" : "플레이리스트에 추가";
add.textContent = "+";
add.addEventListener("click", () => addToPlaylist(item));
@@ -177,7 +327,17 @@
}
function addToPlaylist(item) {
const entry = { ...item, id: createId() };
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();
@@ -193,7 +353,7 @@
state.playlist.forEach((item, index) => {
const row = document.createElement("tr");
row.draggable = true;
row.draggable = !isPlaylistSnapshotLocked();
row.dataset.index = String(index);
row.classList.toggle("active", index === state.currentIndex);
@@ -202,6 +362,7 @@
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", () => {
@@ -224,7 +385,25 @@
const type = document.createElement("span");
type.className = "row-type";
type.textContent = item.category.toUpperCase();
typeCell.append(type);
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";
@@ -242,6 +421,7 @@
}
function selectPlaylistRow(index) {
if (isPlaylistSnapshotLocked()) return;
state.currentIndex = index;
renderPlaylist();
updatePreview();
@@ -252,15 +432,107 @@
if (!item) {
elements.previewTitle.textContent = "선택된 그래픽 없음";
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
renderSceneSelectionForm(null);
renderPlayout();
return;
}
elements.previewTitle.textContent = item.title;
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
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";
@@ -268,6 +540,7 @@
}
function onDragOver(event) {
if (isPlaylistSnapshotLocked()) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
event.currentTarget.classList.add("drag-over");
@@ -275,6 +548,7 @@
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);
@@ -292,6 +566,7 @@
}
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));
@@ -299,6 +574,7 @@
}
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));
@@ -310,6 +586,7 @@
}
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));
@@ -326,10 +603,56 @@
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 = Array.isArray(stored) ? stored : [];
state.playlist = normalizeStoredPlaylist(stored);
state.selectedRows.clear();
state.currentIndex = state.playlist.length ? 0 : -1;
renderPlaylist();
@@ -353,6 +676,73 @@
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));
}
@@ -381,6 +771,33 @@
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];
@@ -395,8 +812,11 @@
const liveMode = isLiveMode();
const testMode = isTestMode();
const bridgeReady = Boolean(nativeBridge);
const outcomeUnknown = state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true;
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);
@@ -465,17 +885,46 @@
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 || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${pageLabel}${changedLabel}`;
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown);
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 (state.playout.nativeOutcomeUnknown) {
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";
@@ -496,22 +945,36 @@
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
}
elements.prepareButton.disabled = !commandReady || !currentCue;
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
elements.nextButton.disabled = !commandReady || !onAir || state.playlist.length === 0;
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 = state.playout.nativeOutcomeUnknown
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown
? "닫기 · 잠금 유지"
: (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();
}
@@ -522,6 +985,7 @@
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);
}
@@ -536,9 +1000,8 @@
return cue ? { cue, targetIndex: state.currentIndex } : null;
}
if (command === "next") {
if (!state.playlist.length) return null;
const targetIndex = state.currentIndex < 0 ? 0 : (state.currentIndex + 1) % state.playlist.length;
return { cue: cueFromItem(state.playlist[targetIndex]), targetIndex };
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;
@@ -552,8 +1015,11 @@
if (state.playout.pending) return;
const blockReason = playoutSafety.commandBlockReason({
pending: state.playout.pending,
outcomeUnknown: state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true,
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
@@ -562,6 +1028,7 @@
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": "종료할 송출 장면이 없습니다."
@@ -591,14 +1058,16 @@
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
const payload = { requestId, command };
if (resolved.cue && (command === "prepare" || command === "next")) {
payload.cue = { code: resolved.cue.code };
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: "송출 엔진 응답 시간이 초과되었습니다.",
@@ -625,6 +1094,8 @@
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;
@@ -649,7 +1120,10 @@
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)
@@ -661,6 +1135,30 @@
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;
@@ -674,7 +1172,13 @@
};
}
renderPlayout();
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 ? "연결" : "미연결"}`);
@@ -708,17 +1212,41 @@
: `${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" || pending.command === "next") {
if (pending.command === "prepare") {
state.playout.preparedCue = pending.cue;
} else if (!state.playout.preparedCode) {
state.playout.preparedCue = null;
}
if (pending.command === "next") {
state.currentIndex = pending.targetIndex;
renderPlaylist();
updatePreview();
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 · " : "";
@@ -933,6 +1461,78 @@
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) {
@@ -982,6 +1582,9 @@
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));
@@ -1171,6 +1774,8 @@
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"));
@@ -1192,6 +1797,9 @@
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"); }
@@ -1202,9 +1810,9 @@
bindEvents();
renderCatalog();
renderPlaylist();
updatePreview();
renderDatabaseStatus();
renderLiveData();
renderPlayout();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");