|
|
|
|
@ -1,4 +1,7 @@
|
|
|
|
|
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
|
|
|
import { invoke } from "@tauri-apps/api/tauri";
|
|
|
|
|
import { appConfigDir, join } from "@tauri-apps/api/path";
|
|
|
|
|
import { BaseDirectory, exists, readTextFile, writeTextFile } from "@tauri-apps/api/fs";
|
|
|
|
|
import type {
|
|
|
|
|
BroadcastConfig,
|
|
|
|
|
ChildWindowAreaConfig,
|
|
|
|
|
@ -14,10 +17,35 @@ import {
|
|
|
|
|
normalizeTotalWidth,
|
|
|
|
|
} from "./segmentService";
|
|
|
|
|
|
|
|
|
|
const CONFIG_KEY = "runtime_broadcast_config";
|
|
|
|
|
const CONFIG_EVENT = "broadcast-config-updated";
|
|
|
|
|
const CONFIG_FILE_NAME = "broadcast-config.json";
|
|
|
|
|
const LEGACY_LOCAL_STORAGE_KEY = "runtime_broadcast_config";
|
|
|
|
|
const LOCAL_STORAGE_KEY = "broadcast_config_local_fallback";
|
|
|
|
|
|
|
|
|
|
function formatError(error: unknown): string {
|
|
|
|
|
if (error instanceof Error) {
|
|
|
|
|
return `${error.name}: ${error.message}`;
|
|
|
|
|
}
|
|
|
|
|
return String(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writeConfigDebugLog(level: "INFO" | "WARN" | "ERROR", message: string) {
|
|
|
|
|
const line = `[config-store][${level}] ${message}`;
|
|
|
|
|
if (level === "ERROR") {
|
|
|
|
|
console.error(line);
|
|
|
|
|
} else if (level === "WARN") {
|
|
|
|
|
console.warn(line);
|
|
|
|
|
} else {
|
|
|
|
|
console.info(line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await invoke("debug_log", { level, message: line });
|
|
|
|
|
} catch {
|
|
|
|
|
// tauri 命令不可用时忽略(例如纯浏览器环境)。
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeFontSize(raw: unknown, fallback: number): number {
|
|
|
|
|
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : fallback;
|
|
|
|
|
}
|
|
|
|
|
@ -43,6 +71,10 @@ function normalizeTextStyle(raw: unknown, fallback: TextStyleConfig): TextStyleC
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCompositeTextStyle(raw: unknown, fallback: TextStyleConfig): TextStyleConfig {
|
|
|
|
|
return normalizeTextStyle(raw, fallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCircleStyle(raw: unknown, fallback: CircleStyleConfig): CircleStyleConfig {
|
|
|
|
|
const source = (raw ?? {}) as Partial<CircleStyleConfig>;
|
|
|
|
|
return {
|
|
|
|
|
@ -69,6 +101,7 @@ function normalizeWindowArea(raw: unknown, index: number): ChildWindowAreaConfig
|
|
|
|
|
const source = (raw ?? {}) as Partial<ChildWindowAreaConfig>;
|
|
|
|
|
return {
|
|
|
|
|
id: typeof source.id === "string" && source.id.trim() ? source.id : `area-${index + 1}`,
|
|
|
|
|
title: typeof source.title === "string" ? source.title : "",
|
|
|
|
|
windowId:
|
|
|
|
|
typeof source.windowId === "number" && Number.isFinite(source.windowId) && source.windowId > 0
|
|
|
|
|
? Math.floor(source.windowId)
|
|
|
|
|
@ -112,6 +145,7 @@ function normalizeSubtitleArea(raw: unknown, index: number): SubtitleAreaConfig
|
|
|
|
|
const source = (raw ?? {}) as Partial<SubtitleAreaConfig>;
|
|
|
|
|
return {
|
|
|
|
|
id: typeof source.id === "string" && source.id.trim() ? source.id : `subtitle-${index + 1}`,
|
|
|
|
|
title: typeof source.title === "string" ? source.title : "",
|
|
|
|
|
width: normalizeFontSize(source.width, 320),
|
|
|
|
|
height: normalizeFontSize(source.height, 32),
|
|
|
|
|
x: typeof source.x === "number" && Number.isFinite(source.x) ? Math.max(0, Math.floor(source.x)) : 0,
|
|
|
|
|
@ -147,6 +181,89 @@ function normalizeConfig(raw: unknown): BroadcastConfig {
|
|
|
|
|
segmentHeight,
|
|
|
|
|
showRuler:
|
|
|
|
|
typeof source.showRuler === "boolean" ? source.showRuler : DEFAULT_BROADCAST_CONFIG.showRuler,
|
|
|
|
|
autoStartSocket:
|
|
|
|
|
typeof source.autoStartSocket === "boolean"
|
|
|
|
|
? source.autoStartSocket
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.autoStartSocket,
|
|
|
|
|
deliveryTargets: {
|
|
|
|
|
syncScreen:
|
|
|
|
|
typeof source.deliveryTargets?.syncScreen === "boolean"
|
|
|
|
|
? source.deliveryTargets.syncScreen
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.deliveryTargets.syncScreen,
|
|
|
|
|
compositeScreen:
|
|
|
|
|
typeof source.deliveryTargets?.compositeScreen === "boolean"
|
|
|
|
|
? source.deliveryTargets.compositeScreen
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.deliveryTargets.compositeScreen,
|
|
|
|
|
voiceBroadcast:
|
|
|
|
|
typeof source.deliveryTargets?.voiceBroadcast === "boolean"
|
|
|
|
|
? source.deliveryTargets.voiceBroadcast
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.deliveryTargets.voiceBroadcast,
|
|
|
|
|
},
|
|
|
|
|
compositeScreen: {
|
|
|
|
|
screenId:
|
|
|
|
|
typeof source.compositeScreen?.screenId === "number" &&
|
|
|
|
|
Number.isFinite(source.compositeScreen.screenId) &&
|
|
|
|
|
source.compositeScreen.screenId > 0
|
|
|
|
|
? Math.floor(source.compositeScreen.screenId)
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.screenId,
|
|
|
|
|
hallName:
|
|
|
|
|
typeof source.compositeScreen?.hallName === "string" && source.compositeScreen.hallName.trim()
|
|
|
|
|
? source.compositeScreen.hallName
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.hallName,
|
|
|
|
|
headerBackgroundColor: normalizeColor(
|
|
|
|
|
source.compositeScreen?.headerBackgroundColor,
|
|
|
|
|
DEFAULT_BROADCAST_CONFIG.compositeScreen.headerBackgroundColor,
|
|
|
|
|
),
|
|
|
|
|
hallStyle: normalizeCompositeTextStyle(
|
|
|
|
|
source.compositeScreen?.hallStyle,
|
|
|
|
|
DEFAULT_BROADCAST_CONFIG.compositeScreen.hallStyle,
|
|
|
|
|
),
|
|
|
|
|
footerSubtitle:
|
|
|
|
|
typeof source.compositeScreen?.footerSubtitle === "string"
|
|
|
|
|
? source.compositeScreen.footerSubtitle
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitle,
|
|
|
|
|
footerBackgroundColor: normalizeColor(
|
|
|
|
|
source.compositeScreen?.footerBackgroundColor,
|
|
|
|
|
DEFAULT_BROADCAST_CONFIG.compositeScreen.footerBackgroundColor,
|
|
|
|
|
),
|
|
|
|
|
footerSubtitleStyle: normalizeCompositeTextStyle(
|
|
|
|
|
source.compositeScreen?.footerSubtitleStyle,
|
|
|
|
|
DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitleStyle,
|
|
|
|
|
),
|
|
|
|
|
footerSubtitleSpeed:
|
|
|
|
|
typeof source.compositeScreen?.footerSubtitleSpeed === "number" &&
|
|
|
|
|
Number.isFinite(source.compositeScreen.footerSubtitleSpeed) &&
|
|
|
|
|
source.compositeScreen.footerSubtitleSpeed > 0
|
|
|
|
|
? source.compositeScreen.footerSubtitleSpeed
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.footerSubtitleSpeed,
|
|
|
|
|
middleBackgroundColor: normalizeColor(
|
|
|
|
|
source.compositeScreen?.middleBackgroundColor,
|
|
|
|
|
DEFAULT_BROADCAST_CONFIG.compositeScreen.middleBackgroundColor,
|
|
|
|
|
),
|
|
|
|
|
middleTextStyle: normalizeCompositeTextStyle(
|
|
|
|
|
source.compositeScreen?.middleTextStyle,
|
|
|
|
|
DEFAULT_BROADCAST_CONFIG.compositeScreen.middleTextStyle,
|
|
|
|
|
),
|
|
|
|
|
middleMaxLines:
|
|
|
|
|
typeof source.compositeScreen?.middleMaxLines === "number" &&
|
|
|
|
|
Number.isFinite(source.compositeScreen.middleMaxLines) &&
|
|
|
|
|
source.compositeScreen.middleMaxLines > 0
|
|
|
|
|
? Math.floor(source.compositeScreen.middleMaxLines)
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.middleMaxLines,
|
|
|
|
|
showVideo:
|
|
|
|
|
typeof source.compositeScreen?.showVideo === "boolean"
|
|
|
|
|
? source.compositeScreen.showVideo
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.showVideo,
|
|
|
|
|
videoUrls:
|
|
|
|
|
typeof source.compositeScreen?.videoUrls === "string"
|
|
|
|
|
? source.compositeScreen.videoUrls
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.videoUrls,
|
|
|
|
|
videoVolume:
|
|
|
|
|
typeof source.compositeScreen?.videoVolume === "number" &&
|
|
|
|
|
Number.isFinite(source.compositeScreen.videoVolume)
|
|
|
|
|
? Math.min(100, Math.max(0, source.compositeScreen.videoVolume))
|
|
|
|
|
: DEFAULT_BROADCAST_CONFIG.compositeScreen.videoVolume,
|
|
|
|
|
},
|
|
|
|
|
segments: segmentsRaw.map((item, index) =>
|
|
|
|
|
normalizeSegmentConfigItem(item, index, screenWidth, segmentHeight),
|
|
|
|
|
),
|
|
|
|
|
@ -192,19 +309,99 @@ async function emitConfigUpdated(config: BroadcastConfig) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 解析跨运行模式稳定的配置文件路径(AppConfigDir/broadcast-config.json)。
|
|
|
|
|
*/
|
|
|
|
|
async function resolveConfigFilePath(): Promise<string | null> {
|
|
|
|
|
try {
|
|
|
|
|
const configDirectory = await appConfigDir();
|
|
|
|
|
const filePath = await join(configDirectory, CONFIG_FILE_NAME);
|
|
|
|
|
await writeConfigDebugLog("INFO", `配置文件路径: ${filePath}`);
|
|
|
|
|
return filePath;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await writeConfigDebugLog("WARN", `解析配置文件路径失败: ${formatError(error)}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 读取持久化配置文件。
|
|
|
|
|
*/
|
|
|
|
|
async function readPersistentConfigFile(): Promise<BroadcastConfig | null> {
|
|
|
|
|
const filePath = await resolveConfigFilePath();
|
|
|
|
|
if (!filePath) {
|
|
|
|
|
await writeConfigDebugLog("WARN", "读取配置中止:无法获取配置文件路径");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const fileExists = await exists(CONFIG_FILE_NAME, { dir: BaseDirectory.AppConfig });
|
|
|
|
|
if (!fileExists) {
|
|
|
|
|
await writeConfigDebugLog("INFO", "配置文件不存在,将走默认配置或迁移逻辑");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const raw = await readTextFile(CONFIG_FILE_NAME, { dir: BaseDirectory.AppConfig });
|
|
|
|
|
if (!raw.trim()) {
|
|
|
|
|
await writeConfigDebugLog("WARN", "配置文件为空,将走默认配置或迁移逻辑");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const parsed = normalizeConfig(JSON.parse(raw));
|
|
|
|
|
await writeConfigDebugLog("INFO", "配置文件读取成功");
|
|
|
|
|
return parsed;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await writeConfigDebugLog("ERROR", `读取配置文件失败: ${formatError(error)}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将配置写入统一配置文件。
|
|
|
|
|
*/
|
|
|
|
|
async function writePersistentConfigFile(config: BroadcastConfig): Promise<boolean> {
|
|
|
|
|
const filePath = await resolveConfigFilePath();
|
|
|
|
|
if (!filePath) {
|
|
|
|
|
await writeConfigDebugLog("WARN", "写入配置中止:无法获取配置文件路径");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await writeTextFile(CONFIG_FILE_NAME, JSON.stringify(config, null, 2), {
|
|
|
|
|
dir: BaseDirectory.AppConfig,
|
|
|
|
|
});
|
|
|
|
|
await writeConfigDebugLog("INFO", `配置文件写入成功: ${filePath}`);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await writeConfigDebugLog("ERROR", `配置文件写入失败: ${formatError(error)}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 读取配置并在首次运行时写入默认配置。
|
|
|
|
|
*/
|
|
|
|
|
export async function loadBroadcastConfig(): Promise<BroadcastConfig> {
|
|
|
|
|
await writeConfigDebugLog("INFO", "开始加载配置");
|
|
|
|
|
const fromFile = await readPersistentConfigFile();
|
|
|
|
|
if (fromFile) {
|
|
|
|
|
await writeConfigDebugLog("INFO", "加载配置完成:来自配置文件");
|
|
|
|
|
return fromFile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const raw = window.localStorage.getItem(CONFIG_KEY);
|
|
|
|
|
// 兼容旧版本 localStorage 配置,并在首次读取后迁移到统一配置文件。
|
|
|
|
|
const raw = window.localStorage.getItem(LEGACY_LOCAL_STORAGE_KEY);
|
|
|
|
|
const saved = raw ? (JSON.parse(raw) as BroadcastConfig) : null;
|
|
|
|
|
const config = saved ? normalizeConfig(saved) : DEFAULT_BROADCAST_CONFIG;
|
|
|
|
|
if (!saved) {
|
|
|
|
|
window.localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
|
|
|
|
}
|
|
|
|
|
const migrated = await writePersistentConfigFile(config);
|
|
|
|
|
await writeConfigDebugLog(
|
|
|
|
|
migrated ? "INFO" : "WARN",
|
|
|
|
|
migrated
|
|
|
|
|
? "配置迁移/初始化写入成功(来源:旧 localStorage 或默认值)"
|
|
|
|
|
: "配置迁移/初始化写入失败,后续将使用回退逻辑",
|
|
|
|
|
);
|
|
|
|
|
return config;
|
|
|
|
|
} catch {
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await writeConfigDebugLog("ERROR", `加载配置异常,改用回退配置: ${formatError(error)}`);
|
|
|
|
|
return getFallbackConfig();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -214,10 +411,13 @@ export async function loadBroadcastConfig(): Promise<BroadcastConfig> {
|
|
|
|
|
*/
|
|
|
|
|
export async function saveBroadcastConfig(payload: BroadcastConfig): Promise<BroadcastConfig> {
|
|
|
|
|
const config = normalizeConfig(payload);
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
|
|
|
|
} catch {
|
|
|
|
|
await writeConfigDebugLog("INFO", "开始保存配置");
|
|
|
|
|
const fileSaved = await writePersistentConfigFile(config);
|
|
|
|
|
if (!fileSaved) {
|
|
|
|
|
await writeConfigDebugLog("WARN", "配置写入文件失败,已回退到 localStorage");
|
|
|
|
|
setFallbackConfig(config);
|
|
|
|
|
} else {
|
|
|
|
|
await writeConfigDebugLog("INFO", "配置保存完成(已写入配置文件)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await emitConfigUpdated(config);
|
|
|
|
|
|