feat(web): single WHIP trunk publish + 4-mode watch (srs-hls/whep/cf/cdn)

This commit is contained in:
noelorin 2026-08-23 15:19:16 +08:00
parent 91b4f01310
commit 5b7edcac5c
39 changed files with 204 additions and 187 deletions

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>t(n,e(i,{iconNode:r,name:`arrow-right`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]],i=i=>t(n,e(i,{iconNode:r,name:`arrow-right`}));export{i as t};

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>t(n,e(i,{iconNode:r,name:`cloud`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]],i=i=>t(n,e(i,{iconNode:r,name:`cloud`}));export{i as t};

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>t(n,e(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>t(n,e(r,{iconNode:a,name:`eye`}));export{i as n,o as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]],i=i=>t(n,e(i,{iconNode:r,name:`eye-off`})),a=[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]],o=r=>t(n,e(r,{iconNode:a,name:`eye`}));export{i as n,o as t};

View File

@ -0,0 +1 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>t(n,e(i,{iconNode:r,name:`globe`}));export{i as t};

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>t(n,e(i,{iconNode:r,name:`hash`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`line`,{x1:`4`,x2:`20`,y1:`9`,y2:`9`,key:`4lhtct`}],[`line`,{x1:`4`,x2:`20`,y1:`15`,y2:`15`,key:`vyu0kd`}],[`line`,{x1:`10`,x2:`8`,y1:`3`,y2:`21`,key:`1ggp8o`}],[`line`,{x1:`16`,x2:`14`,y1:`3`,y2:`21`,key:`weycgp`}]],i=i=>t(n,e(i,{iconNode:r,name:`hash`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]],i=i=>t(n,e(i,{iconNode:r,name:`log-out`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]],i=i=>t(n,e(i,{iconNode:r,name:`log-out`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],i=i=>t(n,e(i,{iconNode:r,name:`message-square`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]],i=i=>t(n,e(i,{iconNode:r,name:`message-square`}));export{i as t};

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],i=i=>t(n,e(i,{iconNode:r,name:`monitor-play`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z`,key:`vbtd3f`}],[`path`,{d:`M12 17v4`,key:`1riwvh`}],[`path`,{d:`M8 21h8`,key:`1ev6f3`}],[`rect`,{x:`2`,y:`3`,width:`20`,height:`14`,rx:`2`,key:`x3v2xh`}]],i=i=>t(n,e(i,{iconNode:r,name:`monitor-play`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>t(n,e(i,{iconNode:r,name:`radio-tower`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M4.9 16.1C1 12.2 1 5.8 4.9 1.9`,key:`s0qx1y`}],[`path`,{d:`M7.8 4.7a6.14 6.14 0 0 0-.8 7.5`,key:`1idnkw`}],[`circle`,{cx:`12`,cy:`9`,r:`2`,key:`1092wv`}],[`path`,{d:`M16.2 4.8c2 2 2.26 5.11.8 7.47`,key:`ojru2q`}],[`path`,{d:`M19.1 1.9a9.96 9.96 0 0 1 0 14.1`,key:`rhi7fg`}],[`path`,{d:`M9.5 18h5`,key:`mfy3pd`}],[`path`,{d:`m8 22 4-11 4 11`,key:`25yftu`}]],i=i=>t(n,e(i,{iconNode:r,name:`radio-tower`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>t(n,e(i,{iconNode:r,name:`server`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`rect`,{width:`20`,height:`8`,x:`2`,y:`2`,rx:`2`,ry:`2`,key:`ngkwjq`}],[`rect`,{width:`20`,height:`8`,x:`2`,y:`14`,rx:`2`,ry:`2`,key:`iecqi9`}],[`line`,{x1:`6`,x2:`6.01`,y1:`6`,y2:`6`,key:`16zg32`}],[`line`,{x1:`6`,x2:`6.01`,y1:`18`,y2:`18`,key:`nzw8ys`}]],i=i=>t(n,e(i,{iconNode:r,name:`server`}));export{i as t};

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-check`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-check`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>t(n,e(r,{iconNode:a,name:`user-round`}));export{i as n,o as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]],i=i=>t(n,e(i,{iconNode:r,name:`shield-alert`})),a=[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]],o=r=>t(n,e(r,{iconNode:a,name:`user-round`}));export{i as n,o as t};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],i=i=>t(n,e(i,{iconNode:r,name:`lock`})),a=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],o=r=>t(n,e(r,{iconNode:a,name:`plus`})),s=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],c=r=>t(n,e(r,{iconNode:s,name:`users`}));export{o as n,i as r,c as t};

View File

@ -1 +0,0 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]],i=i=>t(n,e(i,{iconNode:r,name:`globe`})),a=[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]],o=r=>t(n,e(r,{iconNode:a,name:`lock`})),s=[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]],c=r=>t(n,e(r,{iconNode:s,name:`plus`})),l=[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]],u=r=>t(n,e(r,{iconNode:l,name:`users`}));export{i,c as n,o as r,u as t};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{K as e,R as t,v as n}from"./ui-CzB-gjVP.js";var r=[[`path`,{d:`m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5`,key:`ftymec`}],[`rect`,{x:`2`,y:`6`,width:`14`,height:`12`,rx:`2`,key:`158x01`}]],i=i=>t(n,e(i,{iconNode:r,name:`video`}));export{i as t}; import{G as e,L as t,_ as n}from"./ui-BjxGELcM.js";var r=[[`path`,{d:`m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5`,key:`ftymec`}],[`rect`,{x:`2`,y:`6`,width:`14`,height:`12`,rx:`2`,key:`158x01`}]],i=i=>t(n,e(i,{iconNode:r,name:`video`}));export{i as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,9 +19,9 @@
} }
})() })()
</script> </script>
<script type="module" crossorigin src="/static/assets/index-D9mHcT7c.js"></script> <script type="module" crossorigin src="/static/assets/index-BsK239Bd.js"></script>
<link rel="modulepreload" crossorigin href="/static/assets/ui-CzB-gjVP.js"> <link rel="modulepreload" crossorigin href="/static/assets/ui-BjxGELcM.js">
<link rel="stylesheet" crossorigin href="/static/assets/index-Cj7RYHpI.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-Ub-SlVMy.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -4,6 +4,7 @@ import {
CircleStop, CircleStop,
Cloud, Cloud,
FileVideo, FileVideo,
Globe,
MessageSquare, MessageSquare,
Server, Server,
Video, Video,
@ -14,30 +15,29 @@ import { Alert, Badge, Button, CheckboxRow, Corners, LiveDot, Meter, Reveal } fr
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku' import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import { subscribeRoom } from '../../lib/sse' import { subscribeRoom } from '../../lib/sse'
import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat' import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
import { whipPublish, cfPublish, toRTC } from '../../lib/webrtc' import { whipPublish, toRTC } from '../../lib/webrtc'
import type { BackendInfo, RoomEvent, StreamTarget } from '../../lib/types' import type { DistributorInfo, RoomEvent, StreamTarget } from '../../lib/types'
function backendLabel(kind: string): string { function distributorLabel(kind: string): string {
return kind === 'BACKEND_KIND_CLOUDFLARE' if (kind === 'DISTRIBUTION_KIND_SRS_HLS') return 'SRS HLS 直推'
? 'Cloudflare Realtime' if (kind === 'DISTRIBUTION_KIND_CF_SFU') return 'Cloudflare SFU'
: kind === 'BACKEND_KIND_SRS' if (kind === 'DISTRIBUTION_KIND_CDN') return '第三方 CDN'
? 'SRS' return kind
: kind
} }
function backendIcon(kind: string) { function distributorIcon(kind: string) {
return kind === 'BACKEND_KIND_CLOUDFLARE' ? Cloud : Server if (kind === 'DISTRIBUTION_KIND_SRS_HLS') return Server
if (kind === 'DISTRIBUTION_KIND_CF_SFU') return Cloud
if (kind === 'DISTRIBUTION_KIND_CDN') return Globe
return Server
} }
export const Publish: Component = () => { export const Publish: Component = () => {
const search = useSearch({ from: '/publish' }) const search = useSearch({ from: '/publish' })
const room = () => search().room const room = () => search().room
const [backends, setBackends] = createSignal<BackendInfo[]>([]) const [distributors, setDistributors] = createSignal<DistributorInfo[]>([])
const [selected, setSelected] = createSignal<Record<string, boolean>>({ const [trunkName, setTrunkName] = createSignal('SRS')
BACKEND_KIND_CLOUDFLARE: true,
BACKEND_KIND_SRS: true,
})
const [stream, setStream] = createSignal<MediaStream | null>(null) const [stream, setStream] = createSignal<MediaStream | null>(null)
const [running, setRunning] = createSignal(false) const [running, setRunning] = createSignal(false)
const [targets, setTargets] = createSignal<StreamTarget[]>([]) const [targets, setTargets] = createSignal<StreamTarget[]>([])
@ -46,11 +46,16 @@ export const Publish: Component = () => {
const [showDanmaku, setShowDanmaku] = createSignal(true) const [showDanmaku, setShowDanmaku] = createSignal(true)
let videoEl: HTMLVideoElement | undefined let videoEl: HTMLVideoElement | undefined
const pcs: Record<string, RTCPeerConnection> = {} let pc: RTCPeerConnection | undefined
onMount(() => { onMount(() => {
api.getConfig().then((c) => setBackends(c.backends || [])).catch(() => {}) api.getConfig().then((c) => {
const stopRoom = subscribeRoom(room(), (ev: RoomEvent) => setTargets(ev.targets || [])) setDistributors(c.distributors || [])
if (c.trunk) setTrunkName(c.trunk.name || 'SRS')
}).catch(() => {})
const stopRoom = subscribeRoom(room(), (ev: RoomEvent) => {
setTargets(ev.targets || [])
})
const stopChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100))) const stopChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => { onCleanup(() => {
stopRoom() stopRoom()
@ -62,8 +67,6 @@ export const Publish: Component = () => {
sendChat(room(), text, color).catch(() => {}) sendChat(room(), text, color).catch(() => {})
} }
const toggle = (kind: string) => setSelected((s) => ({ ...s, [kind]: !s[kind] }))
const start = async () => { const start = async () => {
if (!permissions.can('room:publish')) { if (!permissions.can('room:publish')) {
setError('当前角色无权发布(需要 room:publish)') setError('当前角色无权发布(需要 room:publish)')
@ -78,24 +81,14 @@ export const Publish: Component = () => {
if (videoEl) videoEl.srcObject = ms if (videoEl) videoEl.srcObject = ms
} }
const ms = stream()! const ms = stream()!
const wants = backends().filter((b) => selected()[b.kind]) // SRS trunk: single WHIP publish
for (const b of wants) { const resp = await api.publish(room(), 'BACKEND_KIND_SRS', '')
const resp = await api.publish(room(), b.kind, '') pc = await whipPublish({
if (b.kind === 'BACKEND_KIND_SRS') { stream: resp.stream!,
pcs.srs = await whipPublish({ token: resp.publish_token!,
stream: resp.stream!, local: ms,
token: resp.publish_token!, iceServers: (resp.ice_servers || []).map(toRTC),
local: ms, })
iceServers: (resp.ice_servers || []).map(toRTC),
})
} else if (b.kind === 'BACKEND_KIND_CLOUDFLARE') {
pcs.cloudflare = await cfPublish({
sessionId: resp.session_id!,
local: ms,
iceServers: (resp.ice_servers || []).map(toRTC),
})
}
}
} catch (e: any) { } catch (e: any) {
setError(String(e?.message || e)) setError(String(e?.message || e))
setRunning(false) setRunning(false)
@ -103,12 +96,8 @@ export const Publish: Component = () => {
} }
const stopAll = async () => { const stopAll = async () => {
for (const k of Object.keys(pcs)) { try { pc?.close() } catch {}
try { pc = undefined
pcs[k].close()
} catch {}
delete pcs[k]
}
for (const t of targets()) { for (const t of targets()) {
try { try {
await api.stop(room(), t.backend) await api.stop(room(), t.backend)
@ -132,7 +121,7 @@ export const Publish: Component = () => {
<p class="mono-label text-muted-foreground">publish / {room()}</p> <p class="mono-label text-muted-foreground">publish / {room()}</p>
<h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1> <h1 class="display mt-2 text-4xl sm:text-5xl">开始直播</h1>
<p class="mt-3 max-w-lg text-sm text-muted-foreground"> <p class="mt-3 max-w-lg text-sm text-muted-foreground">
选择分发后端,一路推流将扇出到所选 SFU。 推流仅进入 SRS 主干(WHIP),分发由服务端按配置扇出到多路。
</p> </p>
</div> </div>
<div class="flex items-center gap-3 border border-line bg-card/60 px-4 py-2.5"> <div class="flex items-center gap-3 border border-line bg-card/60 px-4 py-2.5">
@ -161,7 +150,7 @@ export const Publish: Component = () => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Video class="size-4 text-acid-lime" /> <Video class="size-4 text-acid-lime" />
<h3 class="font-semibold">本地预览</h3> <h3 class="font-semibold">本地预览</h3>
<span class="micro-label text-muted-foreground">preview</span> <span class="micro-label text-muted-foreground">preview · trunk: {trunkName()}</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="font-mono text-[11px] text-muted-foreground"> <span class="font-mono text-[11px] text-muted-foreground">
@ -185,6 +174,9 @@ export const Publish: Component = () => {
<LiveDot active={running()} /> <LiveDot active={running()} />
{running() ? 'LIVE' : 'STANDBY'} {running() ? 'LIVE' : 'STANDBY'}
</Badge> </Badge>
<Badge variant="secondary" class="border-line bg-black/50 text-white/90 shadow-sm backdrop-blur-sm">
SRS WHIP
</Badge>
</div> </div>
<Show when={!stream()}> <Show when={!stream()}>
<div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2.5 text-white/60"> <div class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2.5 text-white/60">
@ -214,24 +206,25 @@ export const Publish: Component = () => {
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Server class="size-4 text-acid-lime" /> <Server class="size-4 text-acid-lime" />
<h3 class="font-semibold">分发后端</h3> <h3 class="font-semibold">分发方式</h3>
<span class="micro-label text-muted-foreground">routing</span> <span class="micro-label text-muted-foreground">distributions</span>
</div> </div>
<Badge variant="muted">{backends().length} 路</Badge> <Badge variant="muted">{distributors().length} 路</Badge>
</div> </div>
<p class="mt-2 text-xs leading-relaxed text-muted-foreground">由服务端按 DISTRIBUTORS 配置自动扇出,推流端无需选择后端。</p>
<div class="mt-4 space-y-2.5"> <div class="mt-4 space-y-2.5">
<Show <Show
when={backends().length > 0} when={distributors().length > 0}
fallback={<p class="text-sm text-muted-foreground">正在读取后端配置…</p>} fallback={<p class="text-sm text-muted-foreground">正在读取分发配置…</p>}
> >
<For each={backends()}> <For each={distributors()}>
{(b) => { {(d) => {
const Icon = backendIcon(b.kind) const Icon = distributorIcon(d.kind)
return ( return (
<div <div
class={`flex items-center justify-between gap-3 border p-3 transition-colors ${ class={`flex items-center justify-between gap-3 border p-3 transition-colors ${
b.configured ? 'border-line bg-background/50' : 'border-line/60 bg-background/20 opacity-70' d.configured ? 'border-line bg-background/50' : 'border-line/60 bg-background/20 opacity-70'
}`} }`}
> >
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
@ -239,19 +232,14 @@ export const Publish: Component = () => {
<Icon class="size-4" /> <Icon class="size-4" />
</span> </span>
<div class="min-w-0"> <div class="min-w-0">
<CheckboxRow <p class="text-sm font-medium">{d.name}</p>
label={b.name} <p class="micro-label mt-1 text-muted-foreground">
checked={!!selected()[b.kind]} {distributorLabel(d.kind)} · {d.enabled ? '已启用' : '未启用'}
onChange={() => toggle(b.kind)}
disabled={running()}
/>
<p class="micro-label mt-1.5 text-muted-foreground">
{backendLabel(b.kind)} · {b.primary ? '主 SFU' : '对照后端'}
</p> </p>
</div> </div>
</div> </div>
<Badge variant={b.configured ? 'success' : 'muted'}> <Badge variant={d.configured ? 'success' : 'muted'}>
{b.configured ? '在线' : '未配置'} {d.configured ? '就绪' : '未配置'}
</Badge> </Badge>
</div> </div>
) )
@ -261,7 +249,7 @@ export const Publish: Component = () => {
</div> </div>
<p class="mt-4 border-t border-line/70 pt-4 text-xs leading-relaxed text-muted-foreground"> <p class="mt-4 border-t border-line/70 pt-4 text-xs leading-relaxed text-muted-foreground">
推流凭证仅在服务端会话中生效,浏览器端不会出现 SFU 密钥。 主干为 SRS,推流凭证仅在服务端会话中生效。
</p> </p>
</div> </div>
</Reveal> </Reveal>
@ -271,8 +259,8 @@ export const Publish: Component = () => {
<div class="relative border border-line bg-card/50 p-4 sm:p-5"> <div class="relative border border-line bg-card/50 p-4 sm:p-5">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<FileVideo class="size-4 text-acid-lime" /> <FileVideo class="size-4 text-acid-lime" />
<h3 class="font-semibold">分发状态</h3> <h3 class="font-semibold">主干状态</h3>
<span class="micro-label text-muted-foreground">fan-out status</span> <span class="micro-label text-muted-foreground">trunk status</span>
</div> </div>
<Show <Show
when={targets().length > 0} when={targets().length > 0}
@ -288,7 +276,7 @@ export const Publish: Component = () => {
<div class="relative border border-line/80 bg-background/40 p-4"> <div class="relative border border-line/80 bg-background/40 p-4">
<span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" /> <span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" />
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{backendLabel(t.backend)}</span> <span class="truncate text-sm font-medium">SRS 主干</span>
<Badge variant="success"> <Badge variant="success">
<LiveDot active /> <LiveDot active />
直播中 直播中
@ -306,11 +294,6 @@ export const Publish: Component = () => {
</a> </a>
</div> </div>
</Show> </Show>
<Show when={t.session_id}>
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground">
session · {t.session_id}
</p>
</Show>
</div> </div>
)} )}
</For> </For>

View File

@ -5,6 +5,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
FileVideo, FileVideo,
Globe,
MessageSquare, MessageSquare,
MonitorPlay, MonitorPlay,
Radio, Radio,
@ -19,27 +20,32 @@ import { whepSubscribe, cfSubscribe, toRTC } from '../../lib/webrtc'
import { playHLS } from '../../lib/hls' import { playHLS } from '../../lib/hls'
import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat' import { subscribeChat, sendChat, type ChatMessage } from '../../lib/chat'
import { DanmakuOverlay, ChatPanel } from '../../components/danmaku' import { DanmakuOverlay, ChatPanel } from '../../components/danmaku'
import type { RoomEvent } from '../../lib/types' import type { DistributionTarget, RoomEvent } from '../../lib/types'
type Mode = 'cloudflare' | 'srs-whep' | 'srs-hls' type Mode = 'srs-hls' | 'srs-whep' | 'cf-sfu' | 'cdn-hls'
const MODE_LABEL: Record<Mode, string> = { const MODE_LABEL: Record<Mode, string> = {
cloudflare: 'Cloudflare',
'srs-whep': 'SRS WHEP',
'srs-hls': 'SRS HLS', 'srs-hls': 'SRS HLS',
'srs-whep': 'SRS WHEP',
'cf-sfu': 'CF SFU',
'cdn-hls': 'CDN HLS',
} }
function backendText(kind: string) { function distText(kind: string) {
return kind === 'BACKEND_KIND_CLOUDFLARE' ? 'Cloudflare Realtime' : 'SRS' if (kind === 'DISTRIBUTION_KIND_SRS_HLS') return 'SRS HLS'
if (kind === 'DISTRIBUTION_KIND_CF_SFU') return 'CF SFU'
if (kind === 'DISTRIBUTION_KIND_CDN') return 'CDN'
return kind
} }
function RoomHub(props: { room: string }) { function RoomHub(props: { room: string }) {
const navigate = useNavigate() const navigate = useNavigate()
const room = () => props.room const room = () => props.room
const [mode, setMode] = createSignal<Mode>('cloudflare') const [mode, setMode] = createSignal<Mode>('srs-hls')
const [running, setRunning] = createSignal(false) const [running, setRunning] = createSignal(false)
const [error, setError] = createSignal('') const [error, setError] = createSignal('')
const [targets, setTargets] = createSignal<RoomEvent['targets']>([]) const [targets, setTargets] = createSignal<RoomEvent['targets']>([])
const [dists, setDists] = createSignal<DistributionTarget[]>([])
const [msgs, setMsgs] = createSignal<ChatMessage[]>([]) const [msgs, setMsgs] = createSignal<ChatMessage[]>([])
const [showDanmaku, setShowDanmaku] = createSignal(true) const [showDanmaku, setShowDanmaku] = createSignal(true)
@ -48,7 +54,10 @@ function RoomHub(props: { room: string }) {
let stopHls: (() => void) | undefined let stopHls: (() => void) | undefined
onMount(() => { onMount(() => {
const unsubRoom = subscribeRoom(room(), (ev) => setTargets(ev.targets || [])) const unsubRoom = subscribeRoom(room(), (ev) => {
setTargets(ev.targets || [])
setDists(ev.distributions || [])
})
const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100))) const unsubChat = subscribeChat(room(), (m) => setMsgs((p) => [...p, m].slice(-100)))
onCleanup(() => { onCleanup(() => {
unsubRoom() unsubRoom()
@ -79,7 +88,7 @@ function RoomHub(props: { room: string }) {
setError('') setError('')
setRunning(true) setRunning(true)
try { try {
if (mode() === 'cloudflare') { if (mode() === 'cf-sfu') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_CLOUDFLARE', '') const resp = await api.subscribe(room(), 'BACKEND_KIND_CLOUDFLARE', '')
pc = await cfSubscribe({ pc = await cfSubscribe({
viewerSessionId: resp.session_id!, viewerSessionId: resp.session_id!,
@ -91,6 +100,13 @@ function RoomHub(props: { room: string }) {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '') const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
pc = await whepSubscribe({ stream: resp.stream!, iceServers: (resp.ice_servers || []).map(toRTC) }) pc = await whepSubscribe({ stream: resp.stream!, iceServers: (resp.ice_servers || []).map(toRTC) })
attach(pc) attach(pc)
} else if (mode() === 'cdn-hls') {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
if (!videoEl) throw new Error('video element missing')
const cdnDist = dists().find((d) => d.kind === 'DISTRIBUTION_KIND_CDN')
// Prefer CDN URL if available, fallback to /live/cdn path
const url = cdnDist?.url || `/live/cdn/${resp.stream}.m3u8`
stopHls = playHLS(videoEl, url)
} else { } else {
const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '') const resp = await api.subscribe(room(), 'BACKEND_KIND_SRS', '')
if (!videoEl) throw new Error('video element missing') if (!videoEl) throw new Error('video element missing')
@ -119,7 +135,7 @@ function RoomHub(props: { room: string }) {
<div class="flex items-center gap-2.5 border border-line bg-card/60 px-3.5 py-2.5"> <div class="flex items-center gap-2.5 border border-line bg-card/60 px-3.5 py-2.5">
<LiveDot active={targets().length > 0} /> <LiveDot active={targets().length > 0} />
<span class="mono-label"> <span class="mono-label">
{targets().length} targets · {targets().length > 0 ? 'live' : 'idle'} {targets().length} trunk · {dists().length} 分发 · {targets().length > 0 ? 'live' : 'idle'}
</span> </span>
</div> </div>
<Show when={permissions.can('room:publish')}> <Show when={permissions.can('room:publish')}>
@ -149,9 +165,10 @@ function RoomHub(props: { room: string }) {
value={mode()} value={mode()}
onChange={(v) => setMode(v)} onChange={(v) => setMode(v)}
options={[ options={[
{ value: 'cloudflare', label: 'Cloudflare', icon: <Cloud class="size-3.5" /> },
{ value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> },
{ value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay class="size-3.5" /> }, { value: 'srs-hls', label: 'SRS HLS', icon: <MonitorPlay class="size-3.5" /> },
{ value: 'srs-whep', label: 'SRS WHEP', icon: <Radio class="size-3.5" /> },
{ value: 'cf-sfu', label: 'CF SFU', icon: <Cloud class="size-3.5" /> },
{ value: 'cdn-hls', label: 'CDN HLS', icon: <Globe class="size-3.5" /> },
]} ]}
/> />
<span class="hidden font-mono text-[11px] text-muted-foreground sm:block"> <span class="hidden font-mono text-[11px] text-muted-foreground sm:block">
@ -225,45 +242,61 @@ function RoomHub(props: { room: string }) {
<span class="micro-label text-muted-foreground">fan-out status</span> <span class="micro-label text-muted-foreground">fan-out status</span>
</div> </div>
<Show <Show
when={targets().length > 0} when={targets().length > 0 || dists().length > 0}
fallback={ fallback={
<div class="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground"> <div class="mt-4 border border-dashed border-line px-5 py-8 text-center text-sm text-muted-foreground">
房间暂无直播 房间暂无直播
</div> </div>
} }
> >
<div class="mt-4 grid gap-3 md:grid-cols-2"> <div class="mt-4 space-y-3">
<For each={targets()}> <Show when={targets().length > 0}>
{(t) => ( <div class="grid gap-3 md:grid-cols-2">
<div class="relative border border-line/80 bg-background/40 p-4"> <For each={targets()}>
<span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" /> {(t) => (
<div class="flex items-center justify-between gap-2"> <div class="relative border border-line/80 bg-background/40 p-4">
<span class="truncate text-sm font-medium">{backendText(t.backend)}</span> <span class="absolute top-0 left-0 h-3 w-3 border-t-2 border-l-2 border-acid-lime" aria-hidden="true" />
<Badge variant="success"> <div class="flex items-center justify-between gap-2">
<LiveDot active /> <span class="truncate text-sm font-medium">SRS 主干</span>
直播中 <Badge variant="success">
</Badge> <LiveDot active />
</div> 直播中
<Show when={t.stream}> </Badge>
<div class="mt-3 flex flex-wrap gap-2"> </div>
<a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer"> <Show when={t.stream}>
<FileVideo class="size-3" /> <div class="mt-3 flex flex-wrap gap-2">
HLS <a class="mono-chip" href={`/live/${t.stream}.m3u8`} target="_blank" rel="noreferrer">
</a> <FileVideo class="size-3" />
<a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer"> HLS
<FileVideo class="size-3" /> </a>
FLV <a class="mono-chip" href={`/live/${t.stream}.flv`} target="_blank" rel="noreferrer">
</a> <FileVideo class="size-3" />
FLV
</a>
</div>
</Show>
</div> </div>
</Show> )}
<Show when={t.session_id}> </For>
<p class="mt-2 truncate font-mono text-[10px] text-muted-foreground"> </div>
session · {t.session_id} </Show>
</p> <Show when={dists().length > 0}>
</Show> <div class="grid gap-3 md:grid-cols-3">
</div> <For each={dists()}>
)} {(d) => (
</For> <div class="relative border border-line/60 bg-background/30 p-3">
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-medium">{distText(d.kind)}</span>
<Badge variant={d.status === 'ready' ? 'success' : d.status === 'forwarding' ? 'warning' : 'muted'}>
{d.status}
</Badge>
</div>
<p class="mt-1.5 truncate font-mono text-[10px] text-muted-foreground">{d.url}</p>
</div>
)}
</For>
</div>
</Show>
</div> </div>
</Show> </Show>
</div> </div>