diff --git a/electron/src/main/appHost.ts b/electron/src/main/appHost.ts index 71466bf..e69c973 100644 --- a/electron/src/main/appHost.ts +++ b/electron/src/main/appHost.ts @@ -6,13 +6,14 @@ import { app, BrowserWindow, ipcMain, shell } from 'electron' import fs from 'fs' import path from 'path' -const VALID_NAV = new Set(['desk', 'record', 'network', 'proxy', 'system', 'wechat']) +const VALID_NAV = new Set(['desk', 'record', 'network', 'remote', 'system', 'wechat']) export function parseNavFromArgv(argv: string[]): string | null { for (const a of argv) { const m = /^--nav=(.+)$/.exec(a) if (!m) continue const id = m[1].trim() + if (id === 'proxy') return 'network' if (VALID_NAV.has(id)) return id } return null @@ -97,7 +98,7 @@ export function applyAppHostBootstrap(getBackendRoot: () => string): void { program: process.execPath, arguments: '--nav=network', title: '网络', - description: '网络与远程绑定', + description: '网络测速与录制专用代理', iconPath: process.execPath, iconIndex: 0, }, diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 4b098e9..245b5ec 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -42,6 +42,7 @@ import { sendNavToWindow, setAppUserModelIdEarly, } from './appHost' +import { setMainBrowserWindow } from './mainWindowRef' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -399,6 +400,7 @@ async function createWindow(): Promise { nodeIntegration: false, }, }) + setMainBrowserWindow(mainWindow) mainWindow.once('ready-to-show', () => mainWindow?.show()) mainWindow.webContents.once('did-finish-load', () => { appendClientLog('info', 'main', '渲染界面已加载') @@ -452,6 +454,7 @@ async function createWindow(): Promise { } mainWindow.on('closed', () => { + setMainBrowserWindow(null) mainWindow = null }) } diff --git a/electron/src/main/mainWindowRef.ts b/electron/src/main/mainWindowRef.ts new file mode 100644 index 0000000..addd328 --- /dev/null +++ b/electron/src/main/mainWindowRef.ts @@ -0,0 +1,11 @@ +import { BrowserWindow } from 'electron' + +let mainWindowRef: BrowserWindow | null = null + +export function setMainBrowserWindow(win: BrowserWindow | null): void { + mainWindowRef = win +} + +export function getMainBrowserWindow(): BrowserWindow | null { + return mainWindowRef +} diff --git a/electron/src/main/youtubeStudioSidecar.ts b/electron/src/main/youtubeStudioSidecar.ts index fe84b3c..b60c6ea 100644 --- a/electron/src/main/youtubeStudioSidecar.ts +++ b/electron/src/main/youtubeStudioSidecar.ts @@ -3,6 +3,7 @@ * 并可检测页面文本是否包含串流密钥后缀以便与本地配置对应。 */ import { BrowserWindow, app, dialog } from 'electron' +import { getMainBrowserWindow } from './mainWindowRef' import { getYoutubeStudioBrowserMode, launchYoutubeStudioExternalChrome, @@ -102,7 +103,9 @@ export async function ensureYoutubeStudioWindow(streamKeySuffix: string | undefi return } + const parent = getMainBrowserWindow() studioWin = new BrowserWindow({ + ...(parent ? { parent } : {}), width: 1100, height: 720, minWidth: 800, diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 6b75d8c..a337828 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -443,6 +443,13 @@ body.ready { color: var(--text-muted); } +.app-workspace__header-actions { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + .app-workspace__scroll { flex: 1; overflow: auto; @@ -921,6 +928,11 @@ body.ready { 0 0 0 3px var(--accent-soft); } +.input-area--autosize { + min-height: 72px; + field-sizing: content; +} + .btn-row { display: flex; flex-wrap: wrap; @@ -1125,6 +1137,113 @@ body.ready { box-shadow: var(--shadow-inset-top); } +.stream-key-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + +.stream-key-row .select-process { + flex: 0 0 auto; + min-width: 160px; +} + +.stream-key-row__input { + flex: 1 1 220px; + min-width: 200px; +} + +.yt-opt-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.yt-opt-item { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--text-muted); +} + +.yt-opt-item .select-process { + width: 100%; +} + +.live-log-detail { + display: flex; + flex-direction: column; + gap: 12px; + font-size: 13px; +} + +.live-log-detail__row { + display: grid; + grid-template-columns: 140px 1fr; + gap: 10px; + align-items: start; +} + +.live-log-detail__k { + color: var(--text-muted); + font-size: 12px; +} + +.live-log-detail__v { + word-break: break-word; +} + +.live-log-detail__row--top { + align-items: start; +} + +.live-log-detail__v--block { + display: flex; + flex-direction: column; + gap: 6px; +} + +.live-log-detail__preview-line { + word-break: break-all; + line-height: 1.45; +} + +a.live-log-detail__link { + color: var(--accent); + text-decoration: underline; + cursor: pointer; +} + +.live-log-detail__block { + display: flex; + flex-direction: column; + gap: 6px; +} + +.live-log-detail__pre { + margin: 0; + padding: 10px 12px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + background: var(--bg-input); + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); +} + +.live-log-detail__pre--err { + color: #f0a8a8; + border-color: rgba(240, 100, 100, 0.35); +} + .log-wrap { border-radius: var(--radius-md); overflow: hidden; @@ -1206,6 +1325,13 @@ body.ready { flex-shrink: 0; } +.net-dash__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + .net-dash__error { margin: 0 0 12px; padding: 10px 12px; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index f4206dd..d7063ce 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { apiFetch, apiUrl } from './api' import NetworkDashboard from './NetworkDashboard' import RemoteBinding from './RemoteBinding' @@ -12,12 +12,73 @@ import { UI_DROPDOWN_YOUTUBE_ONLY, UI_SHOW_YOUTUBE_GOOGLE_OAUTH, } from './constants' -import { extractDouyinRoomHints, parseYoutubeStreamKeySuffix } from './youtubeConfigUtils' +import { + extractDouyinLiveUrls, + extractDouyinRoomHints, + parseYoutubeStreamKeySuffix, +} from './youtubeConfigUtils' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' type Entry = { script: string; label: string; pm2: string } -type NavId = 'desk' | 'record' | 'network' | 'proxy' | 'system' | 'wechat' +type YoutubeIniModel = { + keys: string[] + activeIndex: number + options: Record + header: string +} + +function youtubeIniActiveKey(m: YoutubeIniModel): string { + if (!m.keys?.length) return '' + const i = Math.max(0, Math.min(m.activeIndex, m.keys.length - 1)) + return (m.keys[i] || '').trim() +} + +type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean } + +type DouyinPreviewRow = { + ok?: boolean + url?: string + room_id?: string + anchor_name?: string + error?: string +} + +function mergeYoutubeOptionsForUi(opts: Record, caps: GpuCaps | null): Record { + const o = { ...opts } + const nv = caps?.nvenc_available ?? false + const capsKnown = caps !== null + const res = (o.output_resolution || '').toLowerCase() + o.output_resolution = res.includes('1080') ? '1080p' : '720p' + + const ug = (o.use_gpu || '').trim() + if (!ug) { + o.use_gpu = capsKnown ? (nv ? '是' : '无') : '是' + } else if (['是', '否', '无'].includes(ug)) { + if (capsKnown && ug === '是' && !nv) o.use_gpu = '无' + } else { + o.use_gpu = capsKnown ? (nv ? '否' : '无') : '否' + } + + const ad = (o.audio_denoise || '').trim().toLowerCase() + if (!ad) o.audio_denoise = '否' + else if (['是', '开', '1', 'true', 'on', 'yes'].includes(ad)) o.audio_denoise = '是' + else o.audio_denoise = '否' + + return o +} + +function formatDurationCn(totalSec: number): string { + if (totalSec < 60) return `${totalSec} 秒` + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + if (m < 60) return `${m} 分 ${s} 秒` + const h = Math.floor(m / 60) + const mm = m % 60 + return `${h} 小时 ${mm} 分` +} + +type NavId = 'desk' | 'record' | 'network' | 'remote' | 'system' | 'wechat' type AppTheme = 'light' | 'dark' @@ -89,9 +150,9 @@ function SidebarBrand({ const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ { id: 'desk', label: '工作台', hint: '事件与综合仪表盘' }, - { id: 'record', label: '录制', hint: '任务与推流控制' }, + { id: 'record', label: '直播', hint: '推流与直播控制' }, { id: 'network', label: '网络', hint: '连通与测速' }, - { id: 'proxy', label: '代理', hint: '仅录制进程' }, + { id: 'remote', label: '远程控制', hint: '扫码绑定与局域网 API' }, { id: 'system', label: '系统', hint: '负载与网速' }, { id: 'wechat', label: '微信', hint: 'Chatbot 与扫码' }, ] @@ -109,9 +170,9 @@ type StatusVariant = | 'unknown' | 'empty' -/** 状态文案:避免技术术语,用「正在录制 / 已停止」等说法 */ +/** 状态文案:避免技术术语,用「正在直播 / 已停止」等说法 */ function statusPresentation(processStatus: string): { line: string; variant: StatusVariant } { - if (processStatus === 'online') return { line: '正在录制', variant: 'online' } + if (processStatus === 'online') return { line: '正在直播', variant: 'online' } if (processStatus === 'stopped') return { line: '已停止', variant: 'stopped' } if (processStatus === 'errored') return { line: '出现异常', variant: 'error' } if (processStatus === 'launching' || processStatus === 'starting') @@ -131,14 +192,26 @@ export default function App() { line: '—', variant: 'loading', }) - const [logText, setLogText] = useState('') const [bootError, setBootError] = useState(null) const [emptyHint, setEmptyHint] = useState(null) - const [youtubeText, setYoutubeText] = useState('') + const [ytIniModel, setYtIniModel] = useState({ + keys: [''], + activeIndex: 0, + options: {}, + header: '', + }) const [urlText, setUrlText] = useState('') const [youtubeStatus, setYoutubeStatus] = useState('就绪') const [urlStatus, setUrlStatus] = useState('就绪') + const [liveDetail, setLiveDetail] = useState<{ + processStatus: string + recentLog: string + recentError: string + }>({ processStatus: 'unknown', recentLog: '', recentError: '' }) + const [liveStartedAtMs, setLiveStartedAtMs] = useState(null) + const [controlNotice, setControlNotice] = useState('') + const [liveTick, setLiveTick] = useState(0) const [loadingAction, setLoadingAction] = useState(null) const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) @@ -156,19 +229,12 @@ export default function App() { { id: string; name: string; keyPreview: string; douyinUrl?: string }[] >([]) const [proChannelsError, setProChannelsError] = useState(null) - const [proYoutubeKey, setProYoutubeKey] = useState('') + const [proYoutubeKeys, setProYoutubeKeys] = useState(['']) + const [proYoutubeActiveIndex, setProYoutubeActiveIndex] = useState(0) const [saveProKeyLoading, setSaveProKeyLoading] = useState(false) - const [ytStudioBrowserMode, setYtStudioBrowserMode] = useState<'embedded' | 'external_chrome'>('embedded') - const [ytStudioChromeResolved, setYtStudioChromeResolved] = useState(null) - const [ytStudioBrowserResolution, setYtStudioBrowserResolution] = useState< - 'bundled' | 'system' | 'runtime' | 'missing' - >('missing') - const [ytStudioUserDataDir, setYtStudioUserDataDir] = useState('') - const [ytStudioBrowserHint, setYtStudioBrowserHint] = useState('') - const [ytStudioDeskReady, setYtStudioDeskReady] = useState(false) - - const logRef = useRef(null) + const [gpuCaps, setGpuCaps] = useState(null) + const [douyinPreviews, setDouyinPreviews] = useState([]) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -187,21 +253,19 @@ export default function App() { }) }, []) + /** 始终使用应用内嵌 Chromium 打开 YouTube Studio 侧窗 */ useEffect(() => { const db = window.deskBridge - if (!db?.getYoutubeStudioBrowserSettings) { - setYtStudioDeskReady(false) - return - } - setYtStudioDeskReady(true) - void db.getYoutubeStudioBrowserSettings().then((s) => { - setYtStudioBrowserMode(s.mode === 'embedded' ? 'embedded' : 'external_chrome') - setYtStudioChromeResolved(s.resolvedChromePath) - setYtStudioUserDataDir(s.userDataDir) - setYtStudioBrowserResolution(s.chromeResolution) - }) + if (!db?.setYoutubeStudioBrowserSettings) return + void db.setYoutubeStudioBrowserSettings({ mode: 'embedded' }) }, []) + useEffect(() => { + if (!liveStartedAtMs) return + const id = window.setInterval(() => setLiveTick((t) => t + 1), 1000) + return () => clearInterval(id) + }, [liveStartedAtMs]) + const applyTheme = useCallback((t: AppTheme) => { setTheme(t) void window.appTheme?.set?.(t) @@ -214,25 +278,18 @@ export default function App() { const nav = raw === 'desk' || raw === 'wechat' ? 'record' - : raw === 'record' || - raw === 'network' || - raw === 'proxy' || - raw === 'system' - ? raw - : null + : raw === 'proxy' + ? 'network' + : raw === 'record' || + raw === 'network' || + raw === 'remote' || + raw === 'system' + ? raw + : null if (nav) setNav(nav) }) }, []) - const scrollLog = useCallback(() => { - const el = logRef.current - if (el) el.scrollTop = el.scrollHeight - }, []) - - useLayoutEffect(() => { - scrollLog() - }, [logText, scrollLog]) - const getEntry = useCallback( (name: string) => entries.find((e) => e.pm2 === name) ?? null, [entries], @@ -244,6 +301,30 @@ export default function App() { const isTiktok = ent ? isTiktokScript(script) : false const isObs = ent ? isObsScript(script) : false + useEffect(() => { + let cancelled = false + void apiFetch(apiUrl('/host/gpu_status')) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) + .then((j: GpuCaps) => { + if (cancelled) return + setGpuCaps({ + nvidia_detected: !!j.nvidia_detected, + nvenc_available: !!j.nvenc_available, + }) + }) + .catch(() => { + if (!cancelled) setGpuCaps({ nvidia_detected: false, nvenc_available: false }) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!isYoutube || multiChannelPro) return + setYtIniModel((m) => ({ ...m, options: mergeYoutubeOptionsForUi(m.options, gpuCaps) })) + }, [gpuCaps, isYoutube, multiChannelPro]) + const loadProcessMonitor = useCallback(async () => { const res = await apiFetch(apiUrl('/process_monitor')) if (!res.ok) throw new Error(String(res.status)) @@ -275,6 +356,63 @@ export default function App() { [currentProcess], ) + const loadYoutubeIniModel = useCallback(async () => { + try { + const res = await apiFetch( + `${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(currentProcess)}`, + ) + const data = (await res.json()) as Partial & { error?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + let keys = Array.isArray(data.keys) && data.keys.length ? data.keys.map(String) : [''] + let ai = + typeof data.activeIndex === 'number' + ? Math.max(0, Math.min(data.activeIndex, keys.length - 1)) + : 0 + if (!multiChannelPro) { + const single = (keys[ai] ?? '').trim() + keys = [single] + ai = 0 + } + let options = data.options && typeof data.options === 'object' ? { ...data.options } : {} + options = mergeYoutubeOptionsForUi(options, gpuCaps) + setYtIniModel({ + keys, + activeIndex: ai, + options, + header: typeof data.header === 'string' ? data.header : '', + }) + setYoutubeStatus('已读取') + } catch (err) { + setYoutubeStatus(`读取失败:${err instanceof Error ? err.message : String(err)}`) + } + }, [currentProcess, multiChannelPro, gpuCaps]) + + const saveYoutubeIniModel = useCallback(async () => { + setSaveYoutubeLoading(true) + try { + const modelToSave = !multiChannelPro + ? { + ...ytIniModel, + keys: [(ytIniModel.keys[0] ?? '').trim()], + activeIndex: 0, + } + : ytIniModel + const res = await apiFetch(`${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(currentProcess)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(modelToSave), + }) + const data = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + setYoutubeStatus(data.message || '已保存') + void window.relayConfig?.importFromConfig?.() + } catch (err) { + setYoutubeStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) + } finally { + setSaveYoutubeLoading(false) + } + }, [currentProcess, ytIniModel, multiChannelPro]) + const loadYtOauthStatus = useCallback(async () => { try { const res = await apiFetch(apiUrl('/youtube/oauth/status')) @@ -326,22 +464,26 @@ export default function App() { const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) if (data.message) setYoutubeStatus(data.message) - await loadConfig('/get_config', setYoutubeText, setYoutubeStatus) + await loadYoutubeIniModel() } catch (err) { setYoutubeStatus(`拉取失败:${err instanceof Error ? err.message : String(err)}`) } finally { setFetchKeyLoading(false) } - }, [currentProcess, loadConfig, multiChannelPro]) + }, [currentProcess, loadYoutubeIniModel, multiChannelPro]) - const saveProChannelKey = useCallback(async () => { + const saveProChannelKeys = useCallback(async () => { if (!proChannelId) return setSaveProKeyLoading(true) try { - const res = await apiFetch(apiUrl('/relay_pro/channel_key'), { + const res = await apiFetch(apiUrl('/relay_pro/channel_keys'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ channelId: proChannelId, youtubeKey: proYoutubeKey }), + body: JSON.stringify({ + channelId: proChannelId, + keys: proYoutubeKeys, + activeIndex: proYoutubeActiveIndex, + }), }) const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) @@ -352,7 +494,7 @@ export default function App() { } finally { setSaveProKeyLoading(false) } - }, [proChannelId, proYoutubeKey]) + }, [proChannelId, proYoutubeKeys, proYoutubeActiveIndex]) const saveProUrlConfig = useCallback(async () => { if (!proChannelId) return @@ -426,7 +568,9 @@ export default function App() { const prev = lastLiveStatusRef.current const prevPs = prev?.process === currentProcess ? prev.status : undefined if (prevPs !== 'online' && ps === 'online') { - relayOnlineSinceRef.current = Date.now() + const t = Date.now() + relayOnlineSinceRef.current = t + setLiveStartedAtMs(t) } if (prevPs === 'online' && (ps === 'stopped' || ps === 'errored')) { const t0 = relayOnlineSinceRef.current @@ -440,6 +584,7 @@ export default function App() { }) } relayOnlineSinceRef.current = null + setLiveStartedAtMs(null) } if (!prev || prev.process !== currentProcess || prev.status !== ps) { lastLiveStatusRef.current = { process: currentProcess, status: ps } @@ -448,28 +593,13 @@ export default function App() { const { line, variant } = statusPresentation(ps) setStatusMeta({ line, variant }) - let textOut = `── 状态一览 · ${new Date().toLocaleTimeString()} ──\n${data.raw_status}\n\n` if (ps === 'not_found') { - const e = getEntry(currentProcess) - const hint = e ? `「${e.label}」` : '当前任务' - textOut += `提示:${hint} 还没有开始运行。请先点「开始录制」。\n` + setLiveDetail({ processStatus: ps, recentLog: '', recentError: '' }) } else { - const rl = data.recent_log as string - const re = data.recent_error as string - textOut += `【正常运行时的输出】\n` - if (rl.includes('不存在') || rl.includes('无日志路径')) { - textOut += `${rl}` - } else { - textOut += `${rl || '(暂无新内容)'}` - } - textOut += `\n\n【异常或报错信息】\n` - if (re.includes('不存在') || re.includes('无日志路径')) { - textOut += `${re}` - } else { - textOut += `${re || '(暂无报错)'}` - } + const rl = String(data.recent_log ?? '') + const re = String(data.recent_error ?? '') + setLiveDetail({ processStatus: ps, recentLog: rl, recentError: re }) } - setLogText(textOut) } else { const e = getEntry(currentProcess) const who = e ? e.label : '当前任务' @@ -488,8 +618,10 @@ export default function App() { detail: { action }, }) if (action === 'start' && isYoutube) { - const keySrc = multiChannelPro ? proYoutubeKey : youtubeText - const suffix = parseYoutubeStreamKeySuffix(keySrc) + const keySrc = multiChannelPro + ? (proYoutubeKeys[proYoutubeActiveIndex] || '').trim() + : youtubeIniActiveKey(ytIniModel) + const suffix = parseYoutubeStreamKeySuffix(`key = ${keySrc}`) const rooms = extractDouyinRoomHints(urlText) const desk = ( window as Window & { @@ -510,11 +642,11 @@ export default function App() { }, }) } - setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`) + setControlNotice(`已对「${who}」执行:${actionLabel}\n${out}`) setTimeout(() => void runPm2Action('status'), 1500) } } catch (err) { - setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}\n\n请检查软件是否完整安装,或稍后重试。`) + setControlNotice(`操作失败:${err instanceof Error ? err.message : String(err)}`) setStatusMeta({ line: '状态未知', variant: 'unknown' }) } finally { if (!isStatus) setLoadingAction(null) @@ -524,11 +656,12 @@ export default function App() { currentProcess, getEntry, isYoutube, - youtubeText, + ytIniModel, urlText, multiChannelPro, proChannelId, - proYoutubeKey, + proYoutubeKeys, + proYoutubeActiveIndex, ], ) @@ -567,8 +700,8 @@ export default function App() { if (!filtered.length) { setEmptyHint( UI_DROPDOWN_YOUTUBE_ONLY - ? '没有发现 YouTube 录制任务。\n若您刚放入程序文件,请关闭软件后重新打开一次。' - : '没有发现可用的录制任务。请确认软件安装目录完整。', + ? '没有发现 YouTube 直播任务。\n若您刚放入程序文件,请关闭软件后重新打开一次。' + : '没有发现可用的直播任务。请确认软件安装目录完整。', ) setStatusMeta({ line: '暂无可选任务', variant: 'empty' }) return @@ -577,7 +710,7 @@ export default function App() { setCurrentProcess(filtered[0].pm2) } catch (e) { setBootError( - `软件内部的录制服务没有启动成功。\n\n若您使用的是完整安装包,请尝试重启电脑后再打开;仍不行请联系技术支持。\n\n(技术信息:${e instanceof Error ? e.message : String(e)})`, + `软件内部的直播服务没有启动成功。\n\n若您使用的是完整安装包,请尝试重启电脑后再打开;仍不行请联系技术支持。\n\n(技术信息:${e instanceof Error ? e.message : String(e)})`, ) setStatusMeta({ line: '未连接', variant: 'offline' }) } @@ -603,9 +736,9 @@ export default function App() { useEffect(() => { if (!ent || !currentProcess) return if (isYoutube && multiChannelPro) return - if (isYoutube) void loadConfig('/get_config', setYoutubeText, setYoutubeStatus) + if (isYoutube) void loadYoutubeIniModel() if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus) - }, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro]) + }, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro, loadYoutubeIniModel]) useEffect(() => { if (!isYoutube || !multiChannelPro) return @@ -650,9 +783,21 @@ export default function App() { if (!cancelled) setYoutubeStatus('读取线路失败') return } - const cd = (await dr.json()) as { youtubeKey?: string } + const cd = (await dr.json()) as { + youtubeKey?: string + youtubeKeys?: string[] + youtubeActiveIndex?: number + } if (cancelled) return - setProYoutubeKey(cd.youtubeKey || '') + const ks = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length + ? cd.youtubeKeys.map(String) + : [cd.youtubeKey || ''] + setProYoutubeKeys(ks.length ? ks : ['']) + setProYoutubeActiveIndex( + typeof cd.youtubeActiveIndex === 'number' + ? Math.max(0, Math.min(cd.youtubeActiveIndex, ks.length - 1)) + : 0, + ) const ur = await apiFetch( apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`), ) @@ -679,9 +824,51 @@ export default function App() { return () => clearInterval(id) }, [isYoutube, loadYtOauthStatus]) - const onProcessChange = (v: string) => { - setCurrentProcess(v) - } + const douyinHints = useMemo( + () => (isYoutube || isTiktok ? extractDouyinRoomHints(urlText) : []), + [isYoutube, isTiktok, urlText], + ) + + useEffect(() => { + if (!isYoutube && !isTiktok) return + const urls = extractDouyinLiveUrls(urlText) + if (!urls.length) { + setDouyinPreviews([]) + return + } + const t = window.setTimeout(() => { + void apiFetch(apiUrl('/douyin/room_previews'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ urls }), + }) + .then((r) => r.json()) + .then((j: { previews?: DouyinPreviewRow[] }) => { + setDouyinPreviews(Array.isArray(j.previews) ? j.previews : []) + }) + .catch(() => setDouyinPreviews([])) + }, 450) + return () => clearTimeout(t) + }, [urlText, isYoutube, isTiktok]) + + const openExternalUrl = useCallback((href: string) => { + const open = window.recorderConsole?.openExternal + if (open) void open(href) + else window.open(href, '_blank', 'noopener,noreferrer') + }, []) + const keySuffixForUi = useMemo(() => { + if (!isYoutube) return '' + if (multiChannelPro) { + const k = (proYoutubeKeys[proYoutubeActiveIndex] || '').trim() + return parseYoutubeStreamKeySuffix(`key = ${k}`) + } + return parseYoutubeStreamKeySuffix(`key = ${youtubeIniActiveKey(ytIniModel)}`) + }, [isYoutube, multiChannelPro, proYoutubeKeys, proYoutubeActiveIndex, ytIniModel]) + + const liveDurationLabel = useMemo(() => { + if (liveStartedAtMs == null) return '—' + return formatDurationCn(Math.floor((Date.now() - liveStartedAtMs) / 1000)) + }, [liveStartedAtMs, liveTick]) if (bootError) { return ( @@ -734,72 +921,8 @@ export default function App() {

{navMeta?.label ?? ''}

{navMeta?.hint}

- {nav === 'record' && ( -
- - {statusMeta.line} -
- )} - - -
- {nav === 'desk' && ( -
- -
- )} - {nav === 'network' && ( -
- - -
- )} - {nav === 'proxy' && ( -
- -
- )} - {nav === 'system' && ( -
- -
- )} - {nav === 'wechat' && ( -
- -
- )} - - {nav === 'record' && ( -
-
-
- - -
- {isYoutube && ( -
+
+ {nav === 'record' && isYoutube && ( + )} + {nav === 'record' && ( +
+ + {statusMeta.line} +
+ )} +
+ + +
+ {nav === 'desk' && ( +
+ +
+ )} + {nav === 'network' && ( +
+ + +
+ )} + {nav === 'remote' && ( +
+ +
+ )} + {nav === 'system' && ( +
+ +
+ )} + {nav === 'wechat' && ( +
+
)} -
- - {ent && ( -
- 当前任务 - {ent.label} - - {ent.script} - -
- )} + {nav === 'record' && ( +
{emptyHint && (
-

还没有可用的录制任务

+

还没有可用的直播任务

{emptyHint}
)} {!emptyHint && (
- {isYoutube && ytStudioDeskReady && ( -
-

YouTube Studio / 直播控制台

-

- 内嵌窗口使用 Electron 自带 Chromium,易被识别为自动化环境。选用外部 Chrome在独立 - profile 中打开官方直播控制台,有利于降低风控。安装包已内置 Windows x64 Chrome for - Testing 完整目录,无需再下载或手动配置;仅在开发跳过打包脚本时才会向用户目录拉取备用副本。 -

-
- 打开方式 -
- - -
-
- {ytStudioBrowserMode === 'external_chrome' && ( -
-
- - -
-
-                      {ytStudioBrowserResolution === 'bundled' && ytStudioChromeResolved
-                        ? `安装包内置 Chrome:${ytStudioChromeResolved}`
-                        : ytStudioBrowserResolution === 'system' && ytStudioChromeResolved
-                          ? `将使用本机已安装的 Chrome:${ytStudioChromeResolved}`
-                          : ytStudioBrowserResolution === 'runtime' && ytStudioChromeResolved
-                            ? `用户目录备用副本:${ytStudioChromeResolved}`
-                            : ytStudioBrowserResolution === 'missing'
-                              ? '正在准备 Chrome(若开发环境未执行 prepare-chrome,启动后会在后台下载备用副本)。'
-                              : '—'}
-                      {ytStudioUserDataDir ? `\n独立登录数据目录:${ytStudioUserDataDir}` : ''}
-                    
- {ytStudioBrowserHint ?
{ytStudioBrowserHint}
: null} -
- )} - {ytStudioBrowserMode === 'embedded' && ( -
-
- -
- {ytStudioBrowserHint ?
{ytStudioBrowserHint}
: null} -
- )} -
- )} +
+

直播开关

+
+ {( + [ + ['start', '开始直播', '开始直播当前任务'], + ['stop', '停止直播', '停止当前直播'], + ['restart', '重新开始', '先停再开'], + ['status', '刷新状态', '马上看最新状态'], + ] as const + ).map(([action, label, title]) => ( + + ))} +
+
{isYoutube && multiChannelPro && (
@@ -957,7 +1027,7 @@ export default function App() {

每条线路对应独立配置文件;下拉选择线路即选择该路的 YouTube 串流密钥。地址列表保存在{' '} config/relay_pro/url_config.<id>.ini,保存时与其它线路去重。 - 点「开始录制」会先把当前选中线路同步到 youtube.ini 与{' '} + 点「开始直播」会先把当前选中线路同步到 youtube.ini 与{' '} URL_config.ini 再启动(当前仍为单路 youtube 进程;多进程并行将在后续版本接入)。

{proChannelsError && ( @@ -1022,20 +1092,70 @@ export default function App() {
)}
- 当前线路 key(youtubeKey) -