From e6e4f7a02e7e8118afdca7da8edbf1379b8cbf32 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 20 May 2026 23:50:43 -0500 Subject: [PATCH] 's' --- config/URL_config.ini | 3 +- config/relay_pro/url_config.ch_1774436412.ini | 3 +- config/relay_pro/url_config.ch_1779328176.ini | 3 +- config/relay_pro/youtube.ch_1774436412.ini | 2 +- config/relay_pro/youtube.ch_1779328176.ini | 7 + config/youtube.ini | 2 +- config/youtube_channels.json | 6 +- electron/scripts/prepare-runtime.mjs | 22 +- electron/scripts/verify-runtime.mjs | 7 +- electron/src/renderer/src/App.css | 8 + electron/src/renderer/src/App.tsx | 244 ++++++++++++------ main.py | 7 +- tiktok2.py | 7 +- web2.py | 169 +++++++++++- web2_client_overview.py | 154 ++++++----- web2_relay_pro.py | 107 ++++++-- youtube2.py | 7 +- 17 files changed, 570 insertions(+), 188 deletions(-) create mode 100644 config/relay_pro/youtube.ch_1779328176.ini diff --git a/config/URL_config.ini b/config/URL_config.ini index f7d3944..cea48af 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1 +1,2 @@ -https://live.douyin.com/116645232423 \ No newline at end of file +https://live.douyin.com/510162746831,主播: 海阳市叁娃面食店 +https://live.douyin.com/622059369101,主播: 于小胖的小摊日记 diff --git a/config/relay_pro/url_config.ch_1774436412.ini b/config/relay_pro/url_config.ch_1774436412.ini index f7d3944..cea48af 100644 --- a/config/relay_pro/url_config.ch_1774436412.ini +++ b/config/relay_pro/url_config.ch_1774436412.ini @@ -1 +1,2 @@ -https://live.douyin.com/116645232423 \ No newline at end of file +https://live.douyin.com/510162746831,主播: 海阳市叁娃面食店 +https://live.douyin.com/622059369101,主播: 于小胖的小摊日记 diff --git a/config/relay_pro/url_config.ch_1779328176.ini b/config/relay_pro/url_config.ch_1779328176.ini index 66df736..f690860 100644 --- a/config/relay_pro/url_config.ch_1779328176.ini +++ b/config/relay_pro/url_config.ch_1779328176.ini @@ -1 +1,2 @@ -124125425235 \ No newline at end of file +https://live.douyin.com/29155980665,主播: 摆摊的小楠 +https://live.douyin.com/709421244480,主播: 阿彪(阿超盖浇饭) \ No newline at end of file diff --git a/config/relay_pro/youtube.ch_1774436412.ini b/config/relay_pro/youtube.ch_1774436412.ini index 4dd77ee..1ffdbe4 100644 --- a/config/relay_pro/youtube.ch_1774436412.ini +++ b/config/relay_pro/youtube.ch_1774436412.ini @@ -1,5 +1,5 @@ [youtube] -key = 112313123124124124124124124df +key = 3pj0-q9k7-surm-kgc0-ce3p2 fast_audio = 1 output_resolution = 720p use_gpu = 无 diff --git a/config/relay_pro/youtube.ch_1779328176.ini b/config/relay_pro/youtube.ch_1779328176.ini new file mode 100644 index 0000000..0d2be2c --- /dev/null +++ b/config/relay_pro/youtube.ch_1779328176.ini @@ -0,0 +1,7 @@ +[youtube] +key = 3pj0-q9k7-surm-kgc0-ce3p +fast_audio = 1 +output_resolution = 720p +use_gpu = 无 +audio_denoise = 否 + diff --git a/config/youtube.ini b/config/youtube.ini index fe96b16..2a897a8 100644 --- a/config/youtube.ini +++ b/config/youtube.ini @@ -1,5 +1,5 @@ [youtube] -key = 112313123124124124124124124df +key = 3pj0-q9k7-surm-kgc0-ce3p2 fast_audio = 1 output_resolution = 720p use_gpu = 无 diff --git a/config/youtube_channels.json b/config/youtube_channels.json index d9a019d..3b93afb 100644 --- a/config/youtube_channels.json +++ b/config/youtube_channels.json @@ -6,9 +6,9 @@ "id": "ch_1774436412", "name": "频道 2", "douyinUrl": "", - "youtubeKey": "112313123124124124124124124df", + "youtubeKey": "3pj0-q9k7-surm-kgc0-ce3p2", "youtubeKeys": [ - "112313123124124124124124124df" + "3pj0-q9k7-surm-kgc0-ce3p2" ] }, { @@ -105,7 +105,7 @@ "id": "ch_1779328176", "name": "频道 17", "douyinUrl": "", - "youtubeKey": "qwr233232352354" + "youtubeKey": "3pj0-q9k7-surm-kgc0-ce3p" } ] } \ No newline at end of file diff --git a/electron/scripts/prepare-runtime.mjs b/electron/scripts/prepare-runtime.mjs index 7e8241d..efdf036 100644 --- a/electron/scripts/prepare-runtime.mjs +++ b/electron/scripts/prepare-runtime.mjs @@ -67,7 +67,7 @@ function enableEmbedSitePackages() { const base = f.replace(/\._pth$/, '') const zipLine = `${base}.zip` // 嵌入式解释器必须显式启用 import site,Lib\\site-packages 才会进 sys.path(pip 才能用) - fs.writeFileSync(p, `${zipLine}\n.\n\nimport site\n`, 'utf8') + fs.writeFileSync(p, `${zipLine}\n.\n..\n\nimport site\n`, 'utf8') } } @@ -75,12 +75,20 @@ function hasWorkingEmbedPython() { if (!fs.existsSync(embedExe)) return false if (fs.existsSync(path.join(runtimeDir, 'pyvenv.cfg'))) return false if (fs.existsSync(path.join(runtimeDir, 'Scripts', 'python.exe'))) return false - const r = spawnSync(embedExe, ['-c', 'import fastapi,uvicorn,requests,psutil'], { - cwd: backendRoot, - encoding: 'utf8', - windowsHide: true, - timeout: 60000, - }) + enableEmbedSitePackages() + const r = spawnSync( + embedExe, + [ + '-c', + 'import importlib.util,fastapi,uvicorn,requests,psutil,src; assert importlib.util.find_spec("web2")', + ], + { + cwd: backendRoot, + encoding: 'utf8', + windowsHide: true, + timeout: 60000, + }, + ) return r.status === 0 } diff --git a/electron/scripts/verify-runtime.mjs b/electron/scripts/verify-runtime.mjs index e9716d2..1c307d7 100644 --- a/electron/scripts/verify-runtime.mjs +++ b/electron/scripts/verify-runtime.mjs @@ -51,7 +51,10 @@ if (!ffmpegExe) { if (pythonExe) { const check = spawnSync( pythonExe, - ['-c', 'import fastapi,uvicorn,requests,psutil;print("runtime_ok")'], + [ + '-c', + 'import importlib.util,fastapi,uvicorn,requests,psutil,src; assert importlib.util.find_spec("web2"); print("runtime_ok")', + ], { cwd: backendRoot, encoding: 'utf8', @@ -61,7 +64,7 @@ if (pythonExe) { ) if (check.status !== 0) { errors.push( - `内置 Python 缺少运行依赖(fastapi/uvicorn/requests/psutil)。\n请先在 ${path.join( + `内置 Python 缺少运行依赖或无法导入后端模块(fastapi/uvicorn/requests/psutil/web2/src)。\n请先在 ${path.join( backendRoot, 'python', )} 环境安装 requirements.txt。\n\nstdout:\n${check.stdout || '(empty)'}\n\nstderr:\n${check.stderr || '(empty)'}`, diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 319d0d3..60c6336 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1929,6 +1929,14 @@ body.ready { box-shadow: var(--shadow-inset-top); } +.log-head .live-tag { + margin-left: auto; +} + +.log-clear-btn { + order: 3; +} + .stream-key-row { display: flex; flex-wrap: wrap; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 3dbbe87..16433f5 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -55,6 +55,10 @@ function proChannelPm2Process(channelId: string): string { return `youtube2__${channelId}` } +function proChannelIdFromPm2Process(process: string): string { + return process.startsWith('youtube2__') ? process.slice('youtube2__'.length) : '' +} + /** 与 web2_relay_pro._norm_stream_key 一致,用于保存前去重校验 */ function normStreamKeyForDedup(k: string): string { let s = (k || '').trim() @@ -442,6 +446,7 @@ export default function App() { const [liveTick, setLiveTick] = useState(0) const [loadingAction, setLoadingAction] = useState(null) + const [clearLogLoading, setClearLogLoading] = useState(false) const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) const [saveUrlLoading, setSaveUrlLoading] = useState(false) @@ -578,6 +583,15 @@ export default function App() { const isYoutube = ent ? isYoutubeScript(script) : false const isTiktok = ent ? isTiktokScript(script) : false const isObs = ent ? isObsScript(script) : false + const legacyYoutubeEntry = useMemo( + () => entries.find((e) => isYoutubeScript(e.script) && !e.pm2.startsWith('youtube2__')) ?? null, + [entries], + ) + const legacyYoutubeProcess = legacyYoutubeEntry?.pm2 ?? '' + const singleModeProcess = + isYoutube && !multiChannelPro && currentProcess.startsWith('youtube2__') && legacyYoutubeProcess + ? legacyYoutubeProcess + : currentProcess useEffect(() => { if (!shellReady) return @@ -623,22 +637,22 @@ export default function App() { const loadConfig = useCallback( async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => { try { - const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`) - if (!res.ok) throw new Error(`连接失败 ${res.status}`) - const data = await res.json() + const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(singleModeProcess)}`) + const data = (await res.json()) as { content?: string; error?: string; message?: string } + if (!res.ok) throw new Error(data.error || data.message || `连接失败 ${res.status}`) setContent(data.content || '') setSt('已读到最新内容') } catch (err) { setSt(`读取失败:${err instanceof Error ? err.message : String(err)}`) } }, - [currentProcess], + [singleModeProcess], ) const loadYoutubeIniModel = useCallback(async () => { try { const res = await apiFetch( - `${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(currentProcess)}`, + `${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(singleModeProcess)}`, ) const data = (await res.json()) as Partial & { error?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) @@ -664,7 +678,7 @@ export default function App() { } catch (err) { setYoutubeStatus(`读取失败:${err instanceof Error ? err.message : String(err)}`) } - }, [currentProcess, multiChannelPro, gpuCaps]) + }, [singleModeProcess, multiChannelPro, gpuCaps]) const saveYoutubeIniModel = useCallback(async () => { setSaveYoutubeLoading(true) @@ -676,7 +690,7 @@ export default function App() { activeIndex: 0, } : ytIniModel - const res = await apiFetch(`${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(currentProcess)}`, { + const res = await apiFetch(`${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(singleModeProcess)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modelToSave), @@ -690,7 +704,7 @@ export default function App() { } finally { setSaveYoutubeLoading(false) } - }, [currentProcess, ytIniModel, multiChannelPro]) + }, [singleModeProcess, ytIniModel, multiChannelPro]) const loadYtOauthStatus = useCallback(async () => { try { @@ -734,7 +748,7 @@ export default function App() { setFetchKeyLoading(true) try { const res = await apiFetch( - `${apiUrl('/youtube/stream/fetch_key')}?process=${encodeURIComponent(currentProcess)}`, + `${apiUrl('/youtube/stream/fetch_key')}?process=${encodeURIComponent(singleModeProcess)}`, { method: 'POST' }, ) const data = (await res.json()) as { error?: string; message?: string } @@ -746,7 +760,7 @@ export default function App() { } finally { setFetchKeyLoading(false) } - }, [currentProcess, loadYoutubeIniModel, multiChannelPro]) + }, [singleModeProcess, loadYoutubeIniModel, multiChannelPro]) const saveProChannelKeysFor = useCallback( async (channelId: string): Promise => { @@ -771,13 +785,36 @@ 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}`) + const claimedNorms = new Set(keysOne.map(normStreamKeyForDedup).filter(Boolean)) setProChannelEditors((prev) => { const cur = prev[channelId] - if (!cur) return prev - return { ...prev, [channelId]: { ...cur, keys: keysOne, activeIndex: 0 } } + const next: typeof prev = { ...prev } + if (claimedNorms.size > 0) { + for (const [id, editor] of Object.entries(prev)) { + if (id === channelId) continue + const kept = editor.keys.filter((k) => !claimedNorms.has(normStreamKeyForDedup(k))) + if (kept.length !== editor.keys.length) { + next[id] = { ...editor, keys: kept.length ? kept : [''], activeIndex: 0 } + } + } + } + if (cur) next[channelId] = { ...cur, keys: keysOne, activeIndex: 0 } + return next }) const cleaned = keysOne.map((s) => String(s).trim()).filter(Boolean) - setCommittedChannelKeys((prev) => ({ ...prev, [channelId]: cleaned })) + setCommittedChannelKeys((prev) => { + const next: typeof prev = { ...prev } + if (claimedNorms.size > 0) { + for (const [id, list] of Object.entries(prev)) { + if (id === channelId) continue + const kept = list.filter((k) => !claimedNorms.has(normStreamKeyForDedup(k))) + if (kept.length) next[id] = kept + else delete next[id] + } + } + next[channelId] = cleaned + return next + }) setYoutubeStatus(data.message || '密钥已保存') void window.relayConfig?.importFromConfig?.() return true @@ -858,13 +895,13 @@ export default function App() { endLoading: () => void, ) => { try { - const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(currentProcess)}`, { + const res = await apiFetch(`${apiUrl(endpoint)}?process=${encodeURIComponent(singleModeProcess)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), }) - if (!res.ok) throw new Error(`保存失败 ${res.status}`) - const data = await res.json() + const data = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(data.error || data.message || `保存失败 ${res.status}`) setSt(data.message || '已保存') } catch (err) { setSt(`未能保存:${err instanceof Error ? err.message : String(err)}`) @@ -872,7 +909,7 @@ export default function App() { endLoading() } }, - [currentProcess], + [singleModeProcess], ) const handlePbAuthModalClose = useCallback(() => { @@ -983,6 +1020,18 @@ export default function App() { } const { line, variant } = statusPresentation(ps) setStatusMeta({ line, variant }) + const proStatusChannelId = proChannelIdFromPm2Process(proc) + if (proStatusChannelId) { + setProRelayStatus((prev) => { + if (!prev?.channelStatuses?.length) return prev + return { + ...prev, + channelStatuses: prev.channelStatuses.map((s) => + s.channelId === proStatusChannelId ? { ...s, live: ps === 'online' } : s, + ), + } + }) + } if (ps === 'not_found') { setLiveDetail({ processStatus: ps, recentLog: '', recentError: '' }) @@ -1037,6 +1086,33 @@ export default function App() { }, }) } + if (action === 'stop') { + relayOnlineSinceRef.current = null + setLiveStartedAtMs(null) + lastLiveStatusRef.current = { process: proc, status: 'stopped' } + setStatusMeta(statusPresentation('stopped')) + const stoppedProChannelId = proChannelIdFromPm2Process(proc) + if (stoppedProChannelId) { + setProRelayStatus((prev) => { + if (!prev?.channelStatuses?.length) return prev + return { + ...prev, + channelStatuses: prev.channelStatuses.map((s) => + s.channelId === stoppedProChannelId ? { ...s, live: false } : s, + ), + } + }) + } + setLiveDetail((prev) => ({ + ...prev, + processStatus: 'stopped', + businessStatus: 'stopped', + businessNote: '已停止', + ffmpegPids: [], + logAgeSeconds: null, + isPushing: false, + })) + } setControlNotice(`已对「${who}」执行:${actionLabel}\n${out}`) setTimeout(() => void runPm2Action('status', processOverride), 1500) } @@ -1050,6 +1126,27 @@ export default function App() { [currentProcess, getEntry, isYoutube, ytIniModel, urlText, multiChannelPro, ensureCanControlLive], ) + const clearCurrentLogs = useCallback(async () => { + const proc = isYoutube && multiChannelPro && highlightedChannelId + ? proChannelPm2Process(highlightedChannelId) + : currentProcess + if (!proc) return + setClearLogLoading(true) + try { + const res = await apiFetch(`${apiUrl('/logs/clear')}?process=${encodeURIComponent(proc)}`, { + method: 'POST', + }) + const data = await readControlResponse(res) + setLiveDetail((prev) => ({ ...prev, recentLog: '', recentError: '', logAgeSeconds: null })) + setControlNotice(String(data.message || '日志已清空')) + setTimeout(() => void runPm2Action('status', proc), 300) + } catch (err) { + setControlNotice(`清空日志失败:${err instanceof Error ? err.message : String(err)}`) + } finally { + setClearLogLoading(false) + } + }, [currentProcess, highlightedChannelId, isYoutube, multiChannelPro, runPm2Action]) + useEffect(() => { document.body.classList.add('ready') }, []) @@ -1120,6 +1217,13 @@ export default function App() { } }, [loadProcessMonitor, shellReady]) + useEffect(() => { + if (multiChannelPro) return + if (!legacyYoutubeProcess) return + if (!currentProcess.startsWith('youtube2__')) return + setCurrentProcess(legacyYoutubeProcess) + }, [currentProcess, legacyYoutubeProcess, multiChannelPro]) + useEffect(() => { if (!entries.length || !currentProcess) return if (isYoutube && multiChannelPro) { @@ -1270,13 +1374,16 @@ export default function App() { if (!allowed) return const pm = proChannelPm2Process(channelId) setLoadingAction('start') + setControlNotice('正在保存线路配置并启动直播...') try { const saved = await saveProChannelKeysFor(channelId) - if (!saved) return - const edBeforeUrlSave = proChannelEditors[channelId] + if (!saved) { + setControlNotice('启动已中止:串流密钥保存失败,请查看配置提示。') + return + } + let edBeforeUrlSave = proChannelEditors[channelId] let urlOverride: string | undefined if ( - !proUrlLoadedChannelIdsRef.current.has(channelId) && countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0 ) { const loadedUrl = await loadProUrlTextFor(channelId, true) @@ -1284,7 +1391,10 @@ export default function App() { urlOverride = loadedUrl } const savedUrl = await saveProUrlFor(channelId, urlOverride) - if (!savedUrl) return + if (!savedUrl) { + setControlNotice('启动已中止:直播地址列表保存失败,请查看配置提示。') + return + } const res = await apiFetch(`${apiUrl('/start')}?process=${encodeURIComponent(pm)}`) const data = await readControlResponse(res) @@ -1336,13 +1446,16 @@ export default function App() { if (!allowed) return const pm = proChannelPm2Process(channelId) setLoadingAction('restart') + setControlNotice('正在保存线路配置并重新启动直播...') try { const saved = await saveProChannelKeysFor(channelId) - if (!saved) return - const edBeforeUrlSave = proChannelEditors[channelId] + if (!saved) { + setControlNotice('重新启动已中止:串流密钥保存失败,请查看配置提示。') + return + } + let edBeforeUrlSave = proChannelEditors[channelId] let urlOverride: string | undefined if ( - !proUrlLoadedChannelIdsRef.current.has(channelId) && countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0 ) { const loadedUrl = await loadProUrlTextFor(channelId, true) @@ -1350,7 +1463,10 @@ export default function App() { urlOverride = loadedUrl } const savedUrl = await saveProUrlFor(channelId, urlOverride) - if (!savedUrl) return + if (!savedUrl) { + setControlNotice('重新启动已中止:直播地址列表保存失败,请查看配置提示。') + return + } const res = await apiFetch(`${apiUrl('/restart')}?process=${encodeURIComponent(pm)}`) const data = await readControlResponse(res) @@ -1731,6 +1847,9 @@ export default function App() { onChange={(e) => { const v = e.target.checked if (!v) { + if (legacyYoutubeProcess) setCurrentProcess(legacyYoutubeProcess) + highlightedChannelIdRef.current = '' + setHighlightedChannelId('') setMultiChannelPro(false) try { localStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, '0') @@ -1742,61 +1861,31 @@ export default function App() { void (async () => { setYoutubeStatus('正在将当前配置同步到多频道…') try { - const r = await apiFetch(apiUrl('/relay_pro/channels')) - const d = (await r.json()) as { - ok?: boolean - channels?: { id: string }[] + const syncRes = await apiFetch(apiUrl('/relay_pro/sync_legacy_first'), { + method: 'POST', + }) + const syncData = (await syncRes.json()) as { + channelId?: string + message?: string error?: string } - if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表') - const firstId = d.channels[0].id - const allKeys = ytIniModel.keys?.length ? ytIniModel.keys.map((k) => String(k)) : [''] - const ai = Math.max(0, Math.min(ytIniModel.activeIndex, Math.max(0, allKeys.length - 1))) - const keys = [(allKeys[ai] ?? allKeys[0] ?? '').trim() || ''] - const keyRes = await apiFetch(apiUrl('/relay_pro/channel_keys'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }), - }) - const kj = (await keyRes.json()) as { error?: string } - if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败') - const urlRes = await apiFetch( - `${apiUrl('/relay_pro/url_config')}?channel_id=${encodeURIComponent(firstId)}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: urlText }), - }, - ) - const uj = (await urlRes.json()) as { error?: string } - if (!urlRes.ok) throw new Error(uj.error || '同步地址列表失败') - try { - await apiFetch( - `${apiUrl('/youtube/ini_model')}?process=${encodeURIComponent(currentProcess)}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - keys, - activeIndex: 0, - options: ytIniModel.options, - header: ytIniModel.header || '', - }), - }, - ) - } catch { - /* 选项同步失败不阻断进入多频道 */ + if (!syncRes.ok || !syncData.channelId) { + throw new Error(syncData.error || '同步第一线路失败') } + const firstProId = syncData.channelId void window.relayConfig?.importFromConfig?.() - highlightedChannelIdRef.current = firstId - setHighlightedChannelId(firstId) + await reloadProChannelsList() + proUrlLoadedChannelIdsRef.current.delete(firstProId) + highlightedChannelIdRef.current = firstProId + setHighlightedChannelId(firstProId) setMultiChannelPro(true) try { localStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, '1') } catch { /* ignore */ } - setYoutubeStatus('已切换到多频道(已沿用当前单路配置)') + setYoutubeStatus(syncData.message || '已切换到多频道 Pro(第一线路沿用单频道配置)') + setTimeout(() => void runPm2Action('status', proChannelPm2Process(firstProId)), 300) } catch (err) { setYoutubeStatus(`切换失败:${err instanceof Error ? err.message : String(err)}`) } @@ -1934,11 +2023,7 @@ export default function App() { highlightedChannelIdRef.current = toId setHighlightedChannelId(toId) if (toId !== fromId) { - setProChannelEditors((prev) => { - const cur = prev[toId] - if (!cur) return prev - return { ...prev, [toId]: { ...cur, urlText: '' } } - }) + proUrlLoadedChannelIdsRef.current.delete(toId) } }} > @@ -2471,6 +2556,15 @@ export default function App() {