From 5996df87029aea3f727a5c818ef52dc86e871c13 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 25 Mar 2026 09:40:29 -0500 Subject: [PATCH] 's' --- config/URL_config.ini | 40 +- config/relay_pro/url_config.ch_1774436412.ini | 3 + config/relay_pro/url_config.ch_1774436414.ini | 3 + config/relay_pro/url_config.ch_1774436422.ini | 3 + config/relay_pro/url_config.ch_1774436429.ini | 3 + config/relay_pro/url_config.ch_1774436432.ini | 3 + config/relay_pro/url_config.ch_1774436839.ini | 3 + config/relay_pro/url_config.ch_1774438186.ini | 3 + config/relay_pro/url_config.ch_1774438189.ini | 3 + config/relay_pro/url_config.ch_1774438190.ini | 3 + .../relay_pro/url_config.ch_1774438190_10.ini | 3 + config/relay_pro/url_config.ch_1774438193.ini | 3 + config/relay_pro/url_config.ch_1774438194.ini | 3 + .../relay_pro/url_config.ch_1774438194_13.ini | 3 + config/relay_pro/url_config.ch_1774438199.ini | 3 + .../relay_pro/url_config.ch_1774438199_15.ini | 3 + config/relay_pro/url_config.ch_1774438395.ini | 2 + config/relay_pro/url_config.ch_1774439571.ini | 3 + config/relay_pro/url_config.ch_default.ini | 4 +- config/relay_pro/youtube.ch_1774438395.ini | 7 + config/relay_pro/youtube.ch_default.ini | 7 + config/youtube.ini | 17 +- config/youtube_channels.json | 102 + douyin_youtube_ffplay.py | 40 +- electron/src/renderer/src/App.css | 183 +- electron/src/renderer/src/App.tsx | 508 +- .../src/renderer/src/WorkspaceDashboard.tsx | 108 +- .../renderer/src/WorkspaceDeskBackdrop.tsx | 11 - electron/src/renderer/src/api.ts | 2 +- mobile/.gitignore | 41 - mobile/.vscode/extensions.json | 1 - mobile/.vscode/settings.json | 7 - mobile/app.json | 48 - mobile/app/(tabs)/_layout.tsx | 64 - mobile/app/(tabs)/bind.tsx | 278 - mobile/app/(tabs)/desk.tsx | 76 - mobile/app/(tabs)/index.tsx | 410 - mobile/app/(tabs)/more.tsx | 177 - mobile/app/(tabs)/network.tsx | 98 - mobile/app/+html.tsx | 38 - mobile/app/+not-found.tsx | 40 - mobile/app/_layout.tsx | 58 - mobile/assets/fonts/SpaceMono-Regular.ttf | Bin 93252 -> 0 bytes .../assets/images/android-icon-background.png | Bin 17549 -> 0 bytes .../assets/images/android-icon-foreground.png | Bin 78796 -> 0 bytes .../assets/images/android-icon-monochrome.png | Bin 4140 -> 0 bytes mobile/assets/images/favicon.png | Bin 1129 -> 0 bytes mobile/assets/images/icon.png | Bin 393493 -> 0 bytes mobile/assets/images/splash-icon.png | Bin 17547 -> 0 bytes mobile/components/EditScreenInfo.tsx | 77 - mobile/components/ExternalLink.tsx | 24 - mobile/components/StyledText.tsx | 5 - mobile/components/Themed.tsx | 45 - mobile/components/useClientOnlyValue.ts | 4 - mobile/components/useClientOnlyValue.web.ts | 12 - mobile/components/useColorScheme.ts | 6 - mobile/components/useColorScheme.web.ts | 8 - mobile/constants/Colors.ts | 18 - mobile/constants/theme.ts | 13 - mobile/context/BindContext.tsx | 49 - mobile/hooks/useRecorder.ts | 416 - mobile/package-lock.json | 8908 ----------------- mobile/package.json | 44 - mobile/pnpm-lock.yaml | 6869 ------------- mobile/tsconfig.json | 17 - web2.py | 260 +- web2_douyin_preview.py | 70 + web2_host_caps.py | 41 + web2_relay_pro.py | 252 +- web2_supervisor.py | 5 +- web2_youtube_ini.py | 102 + youtube2.py | 9 +- 72 files changed, 1347 insertions(+), 18322 deletions(-) create mode 100644 config/relay_pro/url_config.ch_1774436412.ini create mode 100644 config/relay_pro/url_config.ch_1774436414.ini create mode 100644 config/relay_pro/url_config.ch_1774436422.ini create mode 100644 config/relay_pro/url_config.ch_1774436429.ini create mode 100644 config/relay_pro/url_config.ch_1774436432.ini create mode 100644 config/relay_pro/url_config.ch_1774436839.ini create mode 100644 config/relay_pro/url_config.ch_1774438186.ini create mode 100644 config/relay_pro/url_config.ch_1774438189.ini create mode 100644 config/relay_pro/url_config.ch_1774438190.ini create mode 100644 config/relay_pro/url_config.ch_1774438190_10.ini create mode 100644 config/relay_pro/url_config.ch_1774438193.ini create mode 100644 config/relay_pro/url_config.ch_1774438194.ini create mode 100644 config/relay_pro/url_config.ch_1774438194_13.ini create mode 100644 config/relay_pro/url_config.ch_1774438199.ini create mode 100644 config/relay_pro/url_config.ch_1774438199_15.ini create mode 100644 config/relay_pro/url_config.ch_1774438395.ini create mode 100644 config/relay_pro/url_config.ch_1774439571.ini create mode 100644 config/relay_pro/youtube.ch_1774438395.ini create mode 100644 config/relay_pro/youtube.ch_default.ini delete mode 100644 mobile/.gitignore delete mode 100644 mobile/.vscode/extensions.json delete mode 100644 mobile/.vscode/settings.json delete mode 100644 mobile/app.json delete mode 100644 mobile/app/(tabs)/_layout.tsx delete mode 100644 mobile/app/(tabs)/bind.tsx delete mode 100644 mobile/app/(tabs)/desk.tsx delete mode 100644 mobile/app/(tabs)/index.tsx delete mode 100644 mobile/app/(tabs)/more.tsx delete mode 100644 mobile/app/(tabs)/network.tsx delete mode 100644 mobile/app/+html.tsx delete mode 100644 mobile/app/+not-found.tsx delete mode 100644 mobile/app/_layout.tsx delete mode 100644 mobile/assets/fonts/SpaceMono-Regular.ttf delete mode 100644 mobile/assets/images/android-icon-background.png delete mode 100644 mobile/assets/images/android-icon-foreground.png delete mode 100644 mobile/assets/images/android-icon-monochrome.png delete mode 100644 mobile/assets/images/favicon.png delete mode 100644 mobile/assets/images/icon.png delete mode 100644 mobile/assets/images/splash-icon.png delete mode 100644 mobile/components/EditScreenInfo.tsx delete mode 100644 mobile/components/ExternalLink.tsx delete mode 100644 mobile/components/StyledText.tsx delete mode 100644 mobile/components/Themed.tsx delete mode 100644 mobile/components/useClientOnlyValue.ts delete mode 100644 mobile/components/useClientOnlyValue.web.ts delete mode 100644 mobile/components/useColorScheme.ts delete mode 100644 mobile/components/useColorScheme.web.ts delete mode 100644 mobile/constants/Colors.ts delete mode 100644 mobile/constants/theme.ts delete mode 100644 mobile/context/BindContext.tsx delete mode 100644 mobile/hooks/useRecorder.ts delete mode 100644 mobile/package-lock.json delete mode 100644 mobile/package.json delete mode 100644 mobile/pnpm-lock.yaml delete mode 100644 mobile/tsconfig.json create mode 100644 web2_douyin_preview.py create mode 100644 web2_host_caps.py create mode 100644 web2_youtube_ini.py diff --git a/config/URL_config.ini b/config/URL_config.ini index 38645cd..8c2c124 100644 --- a/config/URL_config.ini +++ b/config/URL_config.ini @@ -1,39 +1 @@ -https://live.douyin.com/925673220466,主播: 兜有米麻糍 -https://live.douyin.com/745606325880,主播: w、(摆摊日记_) -https://live.douyin.com/675343045974,主播: 张子沐的串串(你的电子榨菜) -https://live.douyin.com/673565298571,主播: 轻舟已撞大冰山(摆摊vlog) -https://live.douyin.com/835571459859,主播: 海绵饱饱 -https://live.douyin.com/152358755212,主播: 大美_红烧肉 -https://live.douyin.com/990825651731,主播: 王同学摆摊日记(蜜汁烤鸡腿) -https://live.douyin.com/591442402624,主播: 76696515302 -https://live.douyin.com/967381081829,主播: 憨憨的摆摊日记 -https://live.douyin.com/78012762575,主播: 摆摊儿去划水 -https://live.douyin.com/163813589919,主播: 不要熬夜(摆摊中) -https://live.douyin.com/498368994814,主播: 剪头发的小帆帆 -https://live.douyin.com/617848099204,主播: 果妈要努力 -https://live.douyin.com/821525628944,主播: 李熙雨⭐ -https://live.douyin.com/993102287144,主播: 小茗早餐(薪笼记助创官) -https://live.douyin.com/701547125568,主播: 悠悠包 -https://live.douyin.com/816130992220,主播: 甜昕冰冰的摆摊日记 -https://live.douyin.com/876468215361,主播: 晓晓和凡凡 -https://live.douyin.com/511335278313,主播: 秘书(摆摊休息版) -https://live.douyin.com/483160615952,主播: 小胡同学你好(饭团版) -https://live.douyin.com/525514386431,主播: 阿闯烤肉夹馍 -https://live.douyin.com/8687122573,主播: 小彤炒粉 -https://live.douyin.com/642534242822,主播: (小王_)煎饼果子 -https://live.douyin.com/743565594721,主播: 汪汪汪小妞 -https://live.douyin.com/249578288248,主播: 思思努力版 -https://live.douyin.com/472140253414,主播: 戴盘龙 -https://live.douyin.com/700846653732,主播: 三哥大锅菜_炒鸡(国基路店)官方号 -https://live.douyin.com/81849868631,主播: 纯儿²¹(休息勿跑空哦) -https://live.douyin.com/690114366322,主播: 卤味鲜(东大) -https://live.douyin.com/469980190666,主播: 茜茜柠檬茶(小米茶档_) -https://live.douyin.com/388066418744,主播: 加速姐的摆摊日记 -https://live.douyin.com/661694696687,主播: 小米摆摊记 -https://live.douyin.com/228132412943,主播: 00后摆摊小可爱 -https://live.douyin.com/876677865786,主播: 牛哄哄 -https://live.douyin.com/573735790640,主播: 小董卤串(餐饮) -https://live.douyin.com/260608582882,主播: 阿伟在炒饭(90後炒饭) -https://live.douyin.com/210443559964,主播: 小小诺 -https://live.douyin.com/305363702471,主播: 嘉嘉在摆摊 -https://live.douyin.com/143239713951,主播: ɞ_大馋丫头的烤肠 +https://live.douyin.com/116645105013,主播: 张火火摆摊日记 diff --git a/config/relay_pro/url_config.ch_1774436412.ini b/config/relay_pro/url_config.ch_1774436412.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774436412.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774436414.ini b/config/relay_pro/url_config.ch_1774436414.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774436414.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774436422.ini b/config/relay_pro/url_config.ch_1774436422.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774436422.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774436429.ini b/config/relay_pro/url_config.ch_1774436429.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774436429.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774436432.ini b/config/relay_pro/url_config.ch_1774436432.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774436432.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774436839.ini b/config/relay_pro/url_config.ch_1774436839.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774436839.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438186.ini b/config/relay_pro/url_config.ch_1774438186.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438186.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438189.ini b/config/relay_pro/url_config.ch_1774438189.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438189.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438190.ini b/config/relay_pro/url_config.ch_1774438190.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438190.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438190_10.ini b/config/relay_pro/url_config.ch_1774438190_10.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438190_10.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438193.ini b/config/relay_pro/url_config.ch_1774438193.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438193.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438194.ini b/config/relay_pro/url_config.ch_1774438194.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438194.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438194_13.ini b/config/relay_pro/url_config.ch_1774438194_13.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438194_13.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438199.ini b/config/relay_pro/url_config.ch_1774438199.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438199.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438199_15.ini b/config/relay_pro/url_config.ch_1774438199_15.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438199_15.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_1774438395.ini b/config/relay_pro/url_config.ch_1774438395.ini new file mode 100644 index 0000000..2152f61 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774438395.ini @@ -0,0 +1,2 @@ +https://live.douyin.com/28358181657,主播: 罗瑞和夜市_印度飞饼_(阿泽) + diff --git a/config/relay_pro/url_config.ch_1774439571.ini b/config/relay_pro/url_config.ch_1774439571.ini new file mode 100644 index 0000000..f9fe112 --- /dev/null +++ b/config/relay_pro/url_config.ch_1774439571.ini @@ -0,0 +1,3 @@ +# 每行一个抖音直播间地址,例如: +# https://live.douyin.com/123456789 + diff --git a/config/relay_pro/url_config.ch_default.ini b/config/relay_pro/url_config.ch_default.ini index 76c0d05..a6f92c9 100644 --- a/config/relay_pro/url_config.ch_default.ini +++ b/config/relay_pro/url_config.ch_default.ini @@ -1,3 +1 @@ -# 软件已自动创建本文件。每行一个抖音直播间地址,例如: -# https://live.douyin.com/123456789 - +# 每行一个抖音直播间地址 diff --git a/config/relay_pro/youtube.ch_1774438395.ini b/config/relay_pro/youtube.ch_1774438395.ini new file mode 100644 index 0000000..8ceadcb --- /dev/null +++ b/config/relay_pro/youtube.ch_1774438395.ini @@ -0,0 +1,7 @@ +[youtube] +key = h018-hqur-uhtv-5bt2-1x3q +fast_audio = 1 +output_resolution = 720p +use_gpu = 无 +audio_denoise = 否 + diff --git a/config/relay_pro/youtube.ch_default.ini b/config/relay_pro/youtube.ch_default.ini new file mode 100644 index 0000000..02073bb --- /dev/null +++ b/config/relay_pro/youtube.ch_default.ini @@ -0,0 +1,7 @@ +[youtube] +key = ue78-1c3e-mr9g-14mz-9r4z +fast_audio = 1 +output_resolution = 720p +use_gpu = 无 +audio_denoise = 否 + diff --git a/config/youtube.ini b/config/youtube.ini index 4bed05c..f4ea821 100644 --- a/config/youtube.ini +++ b/config/youtube.ini @@ -1,13 +1,6 @@ [youtube] -# 必填:YouTube 直播推流 key(在 YouTube 工作室 → 创建 → 流式传输 获取) -; key = qxvb-r47b-r5ju-6ud3-6k7z -key = x04z-564w-aks7-embw-30y4 - -# 可选:是否使用 RTMPS 加密推流,填 否 则用 RTMP(默认 RTMPS) -; rtmps = 否 - -# 可选:视频比特率 kbps,YouTube 推荐 2500,可填 3000-4500 提高画质 -; bitrate = 2500 - -# 可选:软编 speed 长期小于 1 时开启——轻量音频链(无 arnndn/长 EQ),减轻 CPU。填 1 或 是 -fast_audio = 1 \ No newline at end of file +key = ue78-1c3e-mr9g-14mz-9r4z +fast_audio = 1 +output_resolution = 720p +use_gpu = 无 +audio_denoise = 否 diff --git a/config/youtube_channels.json b/config/youtube_channels.json index 560c226..84bfd51 100644 --- a/config/youtube_channels.json +++ b/config/youtube_channels.json @@ -6,6 +6,108 @@ "id": "ch_default", "name": "默认线路", "douyinUrl": "", + "youtubeKey": "ue78-1c3e-mr9g-14mz-9r4z" + }, + { + "id": "ch_1774436412", + "name": "频道 2", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774436414", + "name": "频道 3", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774436422", + "name": "频道 4", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774436429", + "name": "频道 5", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774436432", + "name": "频道 6", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774436839", + "name": "频道 7", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438186", + "name": "频道 8", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438189", + "name": "频道 9", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438190", + "name": "频道 10", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438190_10", + "name": "频道 11", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438193", + "name": "频道 12", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438194", + "name": "频道 13", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438194_13", + "name": "频道 14", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438199", + "name": "频道 15", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438199_15", + "name": "频道 16", + "douyinUrl": "", + "youtubeKey": "" + }, + { + "id": "ch_1774438395", + "name": "频道 17", + "douyinUrl": "", + "youtubeKey": "h018-hqur-uhtv-5bt2-1x3q" + }, + { + "id": "ch_1774439571", + "name": "频道 18", + "douyinUrl": "", "youtubeKey": "" } ] diff --git a/douyin_youtube_ffplay.py b/douyin_youtube_ffplay.py index 81ea082..c0b07b2 100644 --- a/douyin_youtube_ffplay.py +++ b/douyin_youtube_ffplay.py @@ -83,14 +83,18 @@ def start_douyin_youtube_ffplay( """ # ====================== 读取 YouTube 配置 ====================== - config_file = "config/youtube.ini" + config_file = os.environ.get("YOUTUBE_INI_PATH", "config/youtube.ini") rtmps_base = "rtmps://a.rtmp.youtube.com/live2/" rtmp_base = "rtmp://a.rtmp.youtube.com/live2/" use_rtmps = True - # 720p 竖屏 YouTube 建议 4–6 Mbps,偏低易触发「数据不足/卡顿」提示 + # 720p 竖屏 YouTube 建议 4–6 Mbps;1080p 竖屏适当提高码率 b_v, maxrate, bufsize = "4000k", "4500k", "8000k" stream_key = None - fast_audio = False # config: fast_audio=1 可跳过 arnndn+长 EQ,显著减轻 CPU + fast_audio = False # True=轻量音频链(降噪关) + output_resolution = "720p" + force_software_video = False + audio_denoise_on = False + has_audio_denoise_key = False if os.path.exists(config_file): config = configparser.ConfigParser() @@ -100,6 +104,13 @@ def start_douyin_youtube_ffplay( v = (v or "").strip() if k == "rtmps" and v.lower() in ("0", "false", "否"): use_rtmps = False + elif k == "output_resolution" and v: + output_resolution = v.lower() + elif k == "use_gpu" and v: + force_software_video = v.lower() in ("否", "0", "false", "无", "关", "off") + elif k == "audio_denoise": + has_audio_denoise_key = True + audio_denoise_on = v.lower() in ("1", "true", "yes", "是", "开", "on") elif k == "fast_audio" and v.lower() in ("1", "true", "yes", "是", "开", "on"): fast_audio = True elif k == "bitrate" and v: @@ -110,6 +121,11 @@ def start_douyin_youtube_ffplay( pass elif (k == "key" or k == "stream_key") and v and not v.startswith("#"): stream_key = v + if has_audio_denoise_key: + fast_audio = not audio_denoise_on + + if "1080" in output_resolution: + b_v, maxrate, bufsize = "6500k", "7800k", "13000k" if stream_key: if stream_key.startswith("rtmp://") or stream_key.startswith("rtmps://"): youtube_rtmp = stream_key @@ -177,13 +193,25 @@ def start_douyin_youtube_ffplay( except Exception: pass + if "1080" in output_resolution: + _sw, _sh = "1080", "1920" + else: + _sw, _sh = "720", "1280" + scale_vf = ( + f"fps=30,scale={_sw}:{_sh}:force_original_aspect_ratio=decrease:flags=bilinear," + f"pad={_sw}:{_sh}:(ow-iw)/2:(oh-ih)/2:black" + ) + # ====================== NVENC 检测(符合 YouTube 推荐比特率)====================== codec_v = "libx264" # ultrafast+zerolatency:在软编下尽量让 speed≥1.0;画质略降、直播可接受 video_args = ["-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "high", "-level", "4.0", "-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize] try: - if platform.system().lower() in ["windows", "linux"]: + if ( + not force_software_video + and platform.system().lower() in ["windows", "linux"] + ): check = subprocess.run(["nvidia-smi"], capture_output=True, timeout=5) if check.returncode == 0: enc = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=5) @@ -193,6 +221,8 @@ def start_douyin_youtube_ffplay( "-tune", "ll", "-rc", "cbr", "-b:v", b_v, "-maxrate", maxrate, "-bufsize", bufsize] print("[INFO] 检测到 NVIDIA GPU → 使用 NVENC 硬件加速") + elif force_software_video: + print("[INFO] use_gpu=否/无:强制软件编码 libx264") except Exception as e: print(f"[WARN] NVENC 检测失败,回退软件编码: {e}") @@ -261,7 +291,7 @@ def start_douyin_youtube_ffplay( "-re", "-i", real_url, # bilinear 比 lanczos 轻很多,利于 speed 稳定在 ≥1.0x - "-vf", "fps=30,scale=720:1280:force_original_aspect_ratio=decrease:flags=bilinear,pad=720:1280:(ow-iw)/2:(oh-ih)/2:black", + "-vf", scale_vf, "-c:v", codec_v, *video_args, *_video_mid, diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index ff437ba..741b3bd 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -13,6 +13,7 @@ --bg-card: #22262e; --bg-card-elevated: #282c36; --bg-card-muted: #2a2f3a; + --bg-elevated: #2a2f38; --bg-input: #1e222a; --bg-log-0: #1e2128; --bg-log-1: #14161c; @@ -61,6 +62,7 @@ --bg-card: #ffffff; --bg-card-elevated: #fafbfd; --bg-card-muted: #f0f4fa; + --bg-elevated: #e8eef6; --bg-input: #ffffff; --bg-log-0: #f8fafc; --bg-log-1: #eef2f7; @@ -237,9 +239,20 @@ body.ready { width: 40px; height: 40px; border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; background: linear-gradient(145deg, #3d7eff 0%, #5b9dff 40%, #7c5cff 100%); box-shadow: 0 4px 16px rgba(91, 157, 255, 0.35); flex-shrink: 0; + color: #fff; +} + +.app-sidebar__logo-icon { + width: 22px; + height: 22px; + display: block; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); } .app-sidebar__app-name { @@ -569,7 +582,7 @@ body.ready { background-color: var(--bg-input); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238b929e' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"), - linear-gradient(180deg, #2a2f38 0%, #22262e 100%); + linear-gradient(180deg, var(--bg-input) 0%, var(--bg-card-muted) 100%); background-repeat: no-repeat, no-repeat; background-position: right 12px center, 0 0; background-size: 16px 16px, 100% 100%; @@ -590,6 +603,12 @@ body.ready { box-shadow: var(--shadow-inset-top), 0 0 0 3px var(--accent-soft); } +:root[data-theme="light"] .select-process { + background-image: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"), + linear-gradient(180deg, var(--bg-input) 0%, var(--bg-card-muted) 100%); +} + .select-process:disabled { opacity: 0.55; cursor: not-allowed; @@ -765,6 +784,18 @@ body.ready { .status-pill[data-variant='online'] .status-pill__dot { animation: none; } + + .card--workspace { + animation: none; + } + + .ws-card { + transition: none; + } + + .ws-card:hover { + transform: none; + } } /* 空状态 */ @@ -928,6 +959,16 @@ body.ready { 0 0 0 3px var(--accent-soft); } +:root[data-theme="light"] .input-area { + box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.06); +} + +:root[data-theme="light"] .input-area:focus { + box-shadow: + inset 0 1px 2px rgba(15, 23, 42, 0.06), + 0 0 0 3px var(--accent-soft); +} + .input-area--autosize { min-height: 72px; field-sizing: content; @@ -967,6 +1008,17 @@ body.ready { border-color: rgba(255, 255, 255, 0.18); } +:root[data-theme="light"] .btn-secondary { + background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%); + border-color: var(--border-strong); + box-shadow: var(--shadow-inset-top), var(--shadow-sm); +} + +:root[data-theme="light"] .btn-secondary:hover { + background: linear-gradient(180deg, #f8fafc 0%, #e8eef6 100%); + border-color: rgba(15, 23, 42, 0.18); +} + .btn-primary { background: linear-gradient(180deg, #4a8eef 0%, #3d7eff 45%, #2f6fd6 100%); color: #fff; @@ -988,6 +1040,30 @@ body.ready { transform: translateY(1px); } +.btn-danger { + background: linear-gradient(180deg, #c44 0%, #a33 100%); + color: #fff; + border-color: rgba(0, 0, 0, 0.2); + box-shadow: var(--shadow-inset-top), var(--shadow-sm); +} + +.btn-danger:hover:not(:disabled) { + background: linear-gradient(180deg, #d85555 0%, #b83838 100%); + border-color: rgba(255, 255, 255, 0.12); +} + +:root[data-theme="light"] .btn-danger { + background: linear-gradient(180deg, #fee2e2 0%, #fecaca 100%); + color: #991b1b; + border-color: rgba(220, 38, 38, 0.35); + box-shadow: var(--shadow-inset-top), var(--shadow-sm); +} + +:root[data-theme="light"] .btn-danger:hover:not(:disabled) { + background: linear-gradient(180deg, #fecaca 0%, #fca5a5 100%); + border-color: rgba(220, 38, 38, 0.45); +} + .btn.loading { opacity: 0.72; pointer-events: none; @@ -1325,6 +1401,14 @@ body.ready { background: rgba(91, 157, 255, 0.06); } +.pro-channel-new-draft-row { + padding: 8px 10px; + margin: 0 -10px; + border-radius: var(--radius-md); + border: 1px dashed rgba(91, 157, 255, 0.35); + background: var(--bg-card-muted); +} + .pro-channel-card-header { display: flex; flex-wrap: wrap; @@ -1762,8 +1846,14 @@ a.live-log-detail__link { .net-badge--pending { color: var(--text-muted); - background: var(--bg-elevated); - border: 1px solid var(--border-strong); + background: var(--warn-soft); + border: 1px solid rgba(246, 173, 85, 0.35); +} + +:root[data-theme="light"] .net-badge--pending { + background: rgba(251, 191, 36, 0.18); + border-color: rgba(217, 119, 6, 0.35); + color: #92400e; } .net-badge--ok { @@ -1965,6 +2055,18 @@ button.net-badge { .card--workspace { border-color: rgba(110, 168, 254, 0.25); background: linear-gradient(165deg, rgba(110, 168, 254, 0.06) 0%, var(--bg-card) 100%); + animation: cardWorkspaceIn 0.55s var(--ease) both; +} + +@keyframes cardWorkspaceIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .ws-hero { @@ -1975,6 +2077,19 @@ button.net-badge { min-height: 176px; border: 1px solid var(--border-subtle); box-shadow: var(--shadow-sm); + transition: + box-shadow 0.45s var(--ease), + border-color 0.35s var(--ease); +} + +.ws-hero:hover { + border-color: rgba(91, 157, 255, 0.22); + box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 157, 255, 0.06); +} + +:root[data-theme="light"] .ws-hero:hover { + border-color: rgba(37, 99, 235, 0.2); + box-shadow: var(--shadow-md), 0 0 0 1px rgba(37, 99, 235, 0.06); } .ws-desk-backdrop { @@ -2012,39 +2127,6 @@ button.net-badge { opacity: 0.7; } -.ws-desk-backdrop__legend { - position: absolute; - left: 10px; - bottom: 8px; - z-index: 4; - max-width: min(520px, 94%); - font-size: 10px; - line-height: 1.5; - color: var(--text-muted); - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65); - pointer-events: none; -} - -.ws-desk-backdrop__legend strong { - display: block; - color: var(--text); - font-size: 11px; - margin-bottom: 4px; -} - -.ws-desk-backdrop__legend span { - display: block; -} - -.ws-desk-backdrop__legend-sub { - margin-top: 3px; - opacity: 0.95; -} - -:root[data-theme="light"] .ws-desk-backdrop__legend { - text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9); -} - .ws-head { display: flex; flex-wrap: wrap; @@ -2073,6 +2155,10 @@ button.net-badge { color: #c8cdd5; } +:root[data-theme="light"] .ws-kv-list { + color: var(--text-on-dark); +} + .ws-kv-list li { margin: 0.2rem 0; display: flex; @@ -2246,6 +2332,27 @@ button.net-badge { border-radius: 10px; background: rgba(0, 0, 0, 0.22); border: 1px solid rgba(255, 255, 255, 0.06); + transition: + transform 0.28s var(--ease), + box-shadow 0.35s var(--ease), + border-color 0.28s var(--ease), + background 0.35s var(--ease); +} + +.ws-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22); + border-color: rgba(91, 157, 255, 0.18); +} + +:root[data-theme="light"] .ws-card { + background: var(--bg-card-muted); + border-color: var(--border-card); +} + +:root[data-theme="light"] .ws-card:hover { + box-shadow: var(--shadow-md); + border-color: rgba(37, 99, 235, 0.16); } .ws-card__title { @@ -2255,6 +2362,10 @@ button.net-badge { color: #e8eaed; } +:root[data-theme="light"] .ws-card__title { + color: var(--text); +} + .ws-card__meta { margin: 0 0 0.5rem; font-size: 11px; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index bbfb29d..ef6ce43 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { apiFetch, apiUrl } from './api' import NetworkDashboard from './NetworkDashboard' import RemoteBinding from './RemoteBinding' @@ -67,6 +67,18 @@ function validateStreamKeysUnique(keys: string[]): string | null { return null } +/** 仅含空行与 # 注释时视为空,避免默认模板占满输入框 */ +function douyinUrlListDisplayFromServer(content: string): string { + const lines = String(content ?? '').split(/\r?\n/) + const hasReal = lines.some((line) => { + const t = line.trim() + if (!t) return false + if (t.startsWith('#')) return false + return true + }) + return hasReal ? String(content) : '' +} + type DouyinPreviewRow = { ok?: boolean url?: string @@ -148,6 +160,19 @@ function ThemeIconMoon() { ) } +/** 侧栏品牌区:直播摄像机 + 红点(LIVE 语义) */ +function SidebarBrandLogoIcon() { + return ( + + ) +} + function IconForbidden({ className }: { className?: string }) { return ( -
+
+ +
无人直播助手 @@ -307,10 +334,13 @@ export default function App() { const [saveProKeyLoadingId, setSaveProKeyLoadingId] = useState(null) const [saveUrlLoadingId, setSaveUrlLoadingId] = useState(null) const [highlightedChannelId, setHighlightedChannelId] = useState('') - const [highlightedKeyIndex, setHighlightedKeyIndex] = useState(0) + const highlightedChannelIdRef = useRef('') /** 已保存到服务端的密钥列表(决定直播开关行数);本地草稿多出的行不会出现在开关区 */ const [committedChannelKeys, setCommittedChannelKeys] = useState>({}) const [addChannelBusy, setAddChannelBusy] = useState(false) + /** 点击「新增频道」后出现的草稿行:填写密钥后点保存再真正创建频道 */ + const [newChannelDraft, setNewChannelDraft] = useState<{ keyText: string } | null>(null) + const [deleteChannelBusyId, setDeleteChannelBusyId] = useState(null) const [gpuCaps, setGpuCaps] = useState(null) const [douyinPreviews, setDouyinPreviews] = useState([]) @@ -551,18 +581,15 @@ export default function App() { }, [currentProcess, loadYoutubeIniModel, multiChannelPro]) const saveProChannelKeysFor = useCallback( - async (channelId: string, activeIndexOverride?: number): Promise => { + async (channelId: string): Promise => { const ed = proChannelEditors[channelId] if (!ed) return false - const dupMsg = validateStreamKeysUnique(ed.keys) + const keysOne = [(ed.keys[0] ?? '').trim()] + const dupMsg = validateStreamKeysUnique(keysOne) if (dupMsg) { setYoutubeStatus(dupMsg) return false } - const ai = - typeof activeIndexOverride === 'number' - ? Math.max(0, Math.min(activeIndexOverride, Math.max(0, ed.keys.length - 1))) - : ed.activeIndex setSaveProKeyLoadingId(channelId) try { const res = await apiFetch(apiUrl('/relay_pro/channel_keys'), { @@ -570,8 +597,8 @@ export default function App() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId, - keys: ed.keys, - activeIndex: ai, + keys: keysOne, + activeIndex: 0, }), }) const data = (await res.json()) as { error?: string; message?: string } @@ -579,9 +606,9 @@ export default function App() { setProChannelEditors((prev) => { const cur = prev[channelId] if (!cur) return prev - return { ...prev, [channelId]: { ...cur, activeIndex: ai } } + return { ...prev, [channelId]: { ...cur, keys: keysOne, activeIndex: 0 } } }) - const cleaned = ed.keys.map((s) => String(s).trim()).filter(Boolean) + const cleaned = keysOne.map((s) => String(s).trim()).filter(Boolean) setCommittedChannelKeys((prev) => ({ ...prev, [channelId]: cleaned })) setYoutubeStatus(data.message || '密钥已保存') void window.relayConfig?.importFromConfig?.() @@ -763,6 +790,10 @@ export default function App() { } }, [multiChannelPro]) + useLayoutEffect(() => { + highlightedChannelIdRef.current = highlightedChannelId + }, [highlightedChannelId]) + useEffect(() => { logUserAction('navigate', nav) }, [nav]) @@ -840,74 +871,121 @@ export default function App() { }, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro, loadYoutubeIniModel]) const reloadProChannelsList = useCallback(async () => { - if (!isYoutube || !multiChannelPro) return + if (!isYoutube || !multiChannelPro) return [] try { const r = await apiFetch(apiUrl('/relay_pro/channels')) const d = (await r.json()) as { ok?: boolean; error?: string; channels?: typeof proChannelsList } if (!d.ok) { setProChannelsError(d.error || '无法加载 Pro 频道') setProChannelsList([]) - return + return [] } setProChannelsError(null) - setProChannelsList(d.channels || []) + const list = d.channels || [] + setProChannelsList(list) + return list } catch (e) { setProChannelsError(e instanceof Error ? e.message : String(e)) setProChannelsList([]) + return [] } }, [isYoutube, multiChannelPro]) - const addProChannel = useCallback(async () => { - setAddChannelBusy(true) - try { - const res = await apiFetch(apiUrl('/relay_pro/channel_add'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `频道 ${proChannelsList.length + 1}`, - youtubeKey: '', - }), - }) - const d = (await res.json()) as { error?: string; message?: string; channelId?: string } - if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`) - setYoutubeStatus(d.message || '已新增频道') - await reloadProChannelsList() - void window.relayConfig?.importFromConfig?.() - } catch (e) { - setYoutubeStatus(`新增失败:${e instanceof Error ? e.message : String(e)}`) - } finally { - setAddChannelBusy(false) - } - }, [proChannelsList.length, reloadProChannelsList]) - useEffect(() => { if (!isYoutube || !multiChannelPro) return void reloadProChannelsList() }, [isYoutube, multiChannelPro, reloadProChannelsList]) + const submitNewChannelDraft = useCallback(async () => { + if (!newChannelDraft) return + setAddChannelBusy(true) + try { + const key = newChannelDraft.keyText.trim() + const res = await apiFetch(apiUrl('/relay_pro/channel_add'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: `频道 ${proChannelsList.length + 1}`, + youtubeKey: key, + }), + }) + const d = (await res.json()) as { error?: string; message?: string; channelId?: string } + if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`) + setYoutubeStatus(d.message || '已新增频道') + setNewChannelDraft(null) + await reloadProChannelsList() + void window.relayConfig?.importFromConfig?.() + const nid = (d.channelId || '').trim() + if (nid) { + highlightedChannelIdRef.current = nid + setHighlightedChannelId(nid) + } + } catch (e) { + setYoutubeStatus(`新增失败:${e instanceof Error ? e.message : String(e)}`) + } finally { + setAddChannelBusy(false) + } + }, [newChannelDraft, proChannelsList.length, reloadProChannelsList]) + + const deleteProChannel = useCallback( + async (channelId: string) => { + if (proChannelsList.length <= 1) { + setYoutubeStatus('至少保留一条频道') + return + } + if (!window.confirm('确定删除该频道?将移除配置与独立地址文件,且不可恢复。')) return + setNewChannelDraft(null) + setDeleteChannelBusyId(channelId) + try { + const res = await apiFetch(apiUrl('/relay_pro/channel_delete'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channelId }), + }) + const d = (await res.json()) as { error?: string; message?: string } + if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`) + setYoutubeStatus(d.message || '已删除频道') + setProChannelEditors((prev) => { + const n = { ...prev } + delete n[channelId] + return n + }) + setCommittedChannelKeys((prev) => { + const n = { ...prev } + delete n[channelId] + return n + }) + const nextList = await reloadProChannelsList() + void window.relayConfig?.importFromConfig?.() + if (highlightedChannelIdRef.current === channelId && nextList && nextList.length > 0) { + const nid = nextList[0].id + highlightedChannelIdRef.current = nid + setHighlightedChannelId(nid) + } + } catch (e) { + setYoutubeStatus(`删除失败:${e instanceof Error ? e.message : String(e)}`) + } finally { + setDeleteChannelBusyId(null) + } + }, + [proChannelsList.length, reloadProChannelsList], + ) + useEffect(() => { if (!multiChannelPro) { + highlightedChannelIdRef.current = '' setHighlightedChannelId('') - setHighlightedKeyIndex(0) setCommittedChannelKeys({}) } }, [multiChannelPro]) - const commLenForHighlight = highlightedChannelId - ? committedChannelKeys[highlightedChannelId]?.length ?? 0 - : 0 - useEffect(() => { - if (!multiChannelPro || !highlightedChannelId || commLenForHighlight === 0) return - setHighlightedKeyIndex((k) => (k >= 0 && k < commLenForHighlight ? k : 0)) - }, [multiChannelPro, highlightedChannelId, commLenForHighlight]) - const runProChannelStart = useCallback( - async (channelId: string, keyIndex: number) => { + async (channelId: string) => { if (!channelId) return const pm = proChannelPm2Process(channelId) setLoadingAction('start') try { - const saved = await saveProChannelKeysFor(channelId, keyIndex) + const saved = await saveProChannelKeysFor(channelId) if (!saved) return const res = await apiFetch(`${apiUrl('/start')}?process=${encodeURIComponent(pm)}`) if (!res.ok) throw new Error('无法连接到本机服务') @@ -925,12 +1003,10 @@ export default function App() { eventType: 'control', process: pm, message: '开始直播 已执行', - detail: { action: 'start', channelId, keyIndex }, + detail: { action: 'start', channelId }, }) const ed = proChannelEditors[channelId] - const cleaned = ed ? ed.keys.map((s) => String(s).trim()).filter(Boolean) : [] - const ki = Math.min(Math.max(0, keyIndex), Math.max(0, cleaned.length - 1)) - const keySrc = cleaned[ki] ?? '' + const keySrc = (ed?.keys[0] ?? '').trim() const suffix = parseYoutubeStreamKeySuffix(`key = ${keySrc}`) const rooms = extractDouyinRoomHints(ed?.urlText ?? '') logLiveEvent({ @@ -942,7 +1018,6 @@ export default function App() { keySuffix: suffix, multiChannelPro: true, channelId, - keyIndex, pairingPolicy: 'one_douyin_one_youtube', }, }) @@ -958,12 +1033,12 @@ export default function App() { ) const runProChannelRestart = useCallback( - async (channelId: string, keyIndex: number) => { + async (channelId: string) => { if (!channelId) return const pm = proChannelPm2Process(channelId) setLoadingAction('restart') try { - const saved = await saveProChannelKeysFor(channelId, keyIndex) + const saved = await saveProChannelKeysFor(channelId) if (!saved) return const res = await apiFetch(`${apiUrl('/restart')}?process=${encodeURIComponent(pm)}`) if (!res.ok) throw new Error('无法连接到本机服务') @@ -981,7 +1056,7 @@ export default function App() { eventType: 'control', process: pm, message: '重新开始 已执行', - detail: { action: 'restart', channelId, keyIndex }, + detail: { action: 'restart', channelId }, }) setControlNotice(`已对「${who}」执行:重新开始\n${out}`) setTimeout(() => void runPm2Action('status', pm), 1500) @@ -1038,23 +1113,26 @@ export default function App() { youtubeKeys?: string[] youtubeActiveIndex?: number } - const ks = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length + const ksRaw = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length ? cd.youtubeKeys.map(String) : [cd.youtubeKey || ''] - const ai = - typeof cd.youtubeActiveIndex === 'number' - ? Math.max(0, Math.min(cd.youtubeActiveIndex, Math.max(0, ks.length - 1))) - : 0 - const ur = await apiFetch(apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(c.id)}`)) - const u = ur.ok ? ((await ur.json()) as { content?: string }).content || '' : '' - next[c.id] = { keys: ks.length ? ks : [''], activeIndex: ai, urlText: u } - committed[c.id] = ks.map((s) => String(s).trim()).filter(Boolean) + const k0 = (ksRaw[0] ?? '').trim() + /* Electron:每频道仅一条密钥;服务端若曾有多条,只取第一条同步到 UI */ + /* 不在此拉 url_config:避免慢请求/竞态把 A 的地址写进 B。直播地址仅由「当前高亮」effect + 用户编辑维护 */ + next[c.id] = { keys: [k0 || ''], activeIndex: 0, urlText: '' } + committed[c.id] = k0 ? [k0] : [] } catch { /* skip */ } } if (!cancelled) { - setProChannelEditors(next) + setProChannelEditors((prev) => { + const merged: Record = {} + for (const id of Object.keys(next)) { + merged[id] = { ...next[id], urlText: prev[id]?.urlText ?? '' } + } + return merged + }) setCommittedChannelKeys(committed) } })() @@ -1063,6 +1141,37 @@ export default function App() { } }, [isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) + /** 唯一从服务端填充「直播地址列表」的路径(与全量密钥同步解耦) */ + useEffect(() => { + if (!isYoutube || !multiChannelPro || !highlightedChannelId) return + const cid = highlightedChannelId + let cancelled = false + void (async () => { + try { + const ur = await apiFetch( + apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`), + ) + if (!ur.ok || cancelled) return + const j = (await ur.json()) as { content?: string } + const content = douyinUrlListDisplayFromServer(String(j.content ?? '')) + if (cid !== highlightedChannelIdRef.current) return + setProChannelEditors((prev) => { + const cur = prev[cid] + if (cur) { + return { ...prev, [cid]: { ...cur, urlText: content } } + } + return { ...prev, [cid]: { keys: [''], activeIndex: 0, urlText: content } } + }) + setUrlStatus('已读取') + } catch { + if (!cancelled) setUrlStatus('读取失败') + } + })() + return () => { + cancelled = true + } + }, [highlightedChannelId, isYoutube, multiChannelPro]) + useEffect(() => { if (!UI_SHOW_YOUTUBE_GOOGLE_OAUTH || !isYoutube) return void loadYtOauthStatus() @@ -1117,27 +1226,11 @@ export default function App() { if (!isYoutube) return '' if (multiChannelPro && highlightedChannelId) { const saved = committedChannelKeys[highlightedChannelId] - if (saved?.length) { - const ki = Math.min(Math.max(highlightedKeyIndex, 0), saved.length - 1) - const k = (saved[ki] || '').trim() - return parseYoutubeStreamKeySuffix(`key = ${k}`) - } - const ed = proChannelEditors[highlightedChannelId] - if (!ed?.keys?.length) return '' - const ki = Math.min(Math.max(highlightedKeyIndex, 0), ed.keys.length - 1) - const k = (ed.keys[ki] || '').trim() + const k = ((saved?.[0] ?? proChannelEditors[highlightedChannelId]?.keys?.[0]) || '').trim() return parseYoutubeStreamKeySuffix(`key = ${k}`) } return parseYoutubeStreamKeySuffix(`key = ${youtubeIniActiveKey(ytIniModel)}`) - }, [ - isYoutube, - multiChannelPro, - highlightedChannelId, - highlightedKeyIndex, - committedChannelKeys, - proChannelEditors, - ytIniModel, - ]) + }, [isYoutube, multiChannelPro, highlightedChannelId, committedChannelKeys, proChannelEditors, ytIniModel]) const relayPs = liveDetail.processStatus const isRelayProcessLive = relayPs === 'online' @@ -1151,29 +1244,33 @@ export default function App() { ) const rows: { channelId: string - keyIndex: number channelName: string douyinUrl: string live: boolean }[] = [] for (const c of proChannelsList) { const saved = committedChannelKeys[c.id] - const n = saved?.length ?? 0 - if (n === 0) continue - const live = liveMap.get(c.id) ?? false - for (let i = 0; i < n; i++) { - rows.push({ - channelId: c.id, - keyIndex: i, - channelName: c.name, - douyinUrl: c.douyinUrl ?? '', - live, - }) - } + if (!saved?.length) continue + rows.push({ + channelId: c.id, + channelName: c.name, + douyinUrl: c.douyinUrl ?? '', + live: liveMap.get(c.id) ?? false, + }) } return rows }, [proChannelsList, proRelayStatus, committedChannelKeys]) + /** 多频道下若尚未选中线路,默认高亮直播开关第一行 */ + useEffect(() => { + if (!isYoutube || !multiChannelPro) return + if (highlightedChannelId) return + if (proLiveRows.length === 0) return + const r0 = proLiveRows[0] + highlightedChannelIdRef.current = r0.channelId + setHighlightedChannelId(r0.channelId) + }, [isYoutube, multiChannelPro, highlightedChannelId, proLiveRows]) + const liveDurationLabel = useMemo(() => { if (liveStartedAtMs == null) return '—' return formatDurationCn(Math.floor((Date.now() - liveStartedAtMs) / 1000)) @@ -1258,12 +1355,13 @@ export default function App() { } if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表') const firstId = d.channels[0].id - const keys = ytIniModel.keys?.length ? ytIniModel.keys.map((k) => String(k)) : [''] - const ai = Math.max(0, Math.min(ytIniModel.activeIndex, keys.length - 1)) + 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: ai }), + body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }), }) const kj = (await keyRes.json()) as { error?: string } if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败') @@ -1285,7 +1383,7 @@ export default function App() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys, - activeIndex: ai, + activeIndex: 0, options: ytIniModel.options, header: ytIniModel.header || '', }), @@ -1295,6 +1393,8 @@ export default function App() { /* 选项同步失败不阻断进入多频道 */ } void window.relayConfig?.importFromConfig?.() + highlightedChannelIdRef.current = firstId + setHighlightedChannelId(firstId) setMultiChannelPro(true) try { localStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, '1') @@ -1372,22 +1472,30 @@ export default function App() {
{proLiveRows.map((row) => { const ed = proChannelEditors[row.channelId] - const slotKey = committedChannelKeys[row.channelId]?.[row.keyIndex] ?? '' + const slotKey = committedChannelKeys[row.channelId]?.[0] ?? '' const activeKey = (slotKey || '').trim() const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`) const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') const stopDisabled = pmBusy || !row.live const pm = proChannelPm2Process(row.channelId) - const rowSelected = - row.channelId === highlightedChannelId && row.keyIndex === highlightedKeyIndex + const rowSelected = row.channelId === highlightedChannelId return (
{ - setHighlightedChannelId(row.channelId) - setHighlightedKeyIndex(row.keyIndex) + const fromId = highlightedChannelIdRef.current + const toId = row.channelId + highlightedChannelIdRef.current = toId + setHighlightedChannelId(toId) + if (toId !== fromId) { + setProChannelEditors((prev) => { + const cur = prev[toId] + if (!cur) return prev + return { ...prev, [toId]: { ...cur, urlText: '' } } + }) + } }} > @@ -1407,11 +1515,11 @@ export default function App() { openExternalUrl(href) }} > - {room} · {row.channelName} · #{row.keyIndex + 1} + {room} · {row.channelName} ) : ( - {room} · {row.channelName} · #{row.keyIndex + 1} + {room} · {row.channelName} )}
@@ -1420,10 +1528,10 @@ export default function App() { type="button" className="pro-channel-live-btn pro-channel-live-btn--start" disabled={pmBusy || row.live} - title={row.live ? '该频道已在播' : '使用该密钥槽位启动转播'} + title={row.live ? '该频道已在播' : '仅启动本频道的转播进程'} onClick={() => { if (pmBusy || row.live) return - void runProChannelStart(row.channelId, row.keyIndex) + void runProChannelStart(row.channelId) }} > {(pmBusy || row.live) && } @@ -1446,7 +1554,7 @@ export default function App() { type="button" className="pro-channel-live-btn pro-channel-live-btn--restart" disabled={pmBusy} - onClick={() => void runProChannelRestart(row.channelId, row.keyIndex)} + onClick={() => void runProChannelRestart(row.channelId)} > {pmBusy && } 重新开始 @@ -1459,7 +1567,7 @@ export default function App() { ) : isYoutube && multiChannelPro && proLiveRows.length === 0 ? (

{proChannelsList.length > 0 - ? '暂无已保存的线路密钥。请在「频道」中保存串流密钥后,对应行将显示于此。' + ? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。' : '暂无频道。'}

) : ( @@ -1531,17 +1639,7 @@ export default function App() { )}
-
-

频道

- -
+

当前频道

{!highlightedChannelId ? (

请先在上方「直播开关」中点击一行,以编辑该线路的串流密钥。

) : ( @@ -1551,107 +1649,101 @@ export default function App() { const ed = proChannelEditors[ch.id] return (
-

- 当前线路:{ch.name} -

{!ed ? (

加载中…

) : ( <>
- 串流密钥
- {ed.keys.map((k, i) => ( -
- #{i + 1} +
+ { + const v = e.target.value + setProChannelEditors((prev) => { + const cur = prev[ch.id] + if (!cur) return prev + return { ...prev, [ch.id]: { ...cur, keys: [v], activeIndex: 0 } } + }) + }} + /> +
+ + +
+
+ {newChannelDraft ? ( +
{ - const v = e.target.value - setProChannelEditors((prev) => { - const cur = prev[ch.id] - if (!cur) return prev - const nextKeys = [...cur.keys] - nextKeys[i] = v - return { ...prev, [ch.id]: { ...cur, keys: nextKeys } } - }) - }} + aria-label="新频道串流密钥" + placeholder="新频道串流密钥(可先填再保存创建)" + value={newChannelDraft.keyText} + onChange={(e) => + setNewChannelDraft((d) => + d ? { ...d, keyText: e.target.value } : d, + ) + } />
- ))} -
-
- + ) : null}
+ {!newChannelDraft ? ( +
+ +
+ ) : null}
)} @@ -1668,10 +1760,6 @@ export default function App() { return (

直播地址列表

-

- 当前线路:{ch.name}。此处读取/保存该线路独立生成的直播地址配置文件(与客户端 - SQLite 线路数据一致)。 -

{!ed ? (

加载中…

) : ( @@ -1681,6 +1769,8 @@ export default function App() { className="input-area input-area--autosize" spellCheck={false} autoComplete="off" + aria-label="抖音直播间地址列表" + placeholder="要转播的直播间地址写在这里,一行一个。" rows={Math.min( 18, Math.max(3, ed.urlText.split(/\r?\n/).length + 1), @@ -1710,7 +1800,13 @@ export default function App() { setProChannelEditors((prev) => { const cur = prev[ch.id] if (!cur) return prev - return { ...prev, [ch.id]: { ...cur, urlText: u.content || '' } } + return { + ...prev, + [ch.id]: { + ...cur, + urlText: douyinUrlListDisplayFromServer(u.content || ''), + }, + } }) setUrlStatus('已重新读取') } diff --git a/electron/src/renderer/src/WorkspaceDashboard.tsx b/electron/src/renderer/src/WorkspaceDashboard.tsx index a74f62e..ab867e5 100644 --- a/electron/src/renderer/src/WorkspaceDashboard.tsx +++ b/electron/src/renderer/src/WorkspaceDashboard.tsx @@ -202,7 +202,6 @@ export default function WorkspaceDashboard() { const net = data?.snapshots?.network_status const sys = data?.snapshots?.system_metrics - const w = overview?.wechat return (
@@ -217,7 +216,7 @@ export default function WorkspaceDashboard() {

- 工作台 + 仪表盘