feat: migrate legacy playout workflow and scenes
This commit is contained in:
694
Web/app.js
694
Web/app.js
@@ -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 운영 화면 시작");
|
||||
|
||||
Reference in New Issue
Block a user