Initial commit
This commit is contained in:
commit
bee67543dd
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Local Deno cache
|
||||||
|
.deno/
|
||||||
|
|
||||||
|
# Node cache, only needed if you use Node-based local tooling
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Runtime output
|
||||||
|
media/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
132
README.md
Normal file
132
README.md
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# Deno Desktop Stream
|
||||||
|
|
||||||
|
一个简易的局域网桌面分享命令行应用。主机在浏览器选择桌面、窗口或应用进行分享,客机通过主机端口打开观看页面。
|
||||||
|
|
||||||
|
当前传输链路使用 WebRTC,服务端只负责页面托管和 WebSocket 信令转发。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 浏览器原生屏幕/窗口/应用采集
|
||||||
|
- 可选系统音频或标签页音频,取决于浏览器和被分享内容
|
||||||
|
- 局域网低延迟观看
|
||||||
|
- 主机页面和观看页面由同一个服务提供
|
||||||
|
- 可打包为单个 Windows exe
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- Deno 2.x
|
||||||
|
|
||||||
|
Deno 仅用于开发、检查和打包。打包后的 exe 不需要安装 Deno、Node.js、npm、FFmpeg 或 node-media-server。
|
||||||
|
|
||||||
|
观看和分享需要现代浏览器,建议使用 Chrome 或 Edge。
|
||||||
|
|
||||||
|
## 开发运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
deno task start
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听 `8080`:
|
||||||
|
|
||||||
|
- 主机页面:`http://localhost:8080/`
|
||||||
|
- 观看页面:`http://localhost:8080/viewer`
|
||||||
|
|
||||||
|
自定义端口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
deno run -A src/main.ts --port 9090
|
||||||
|
```
|
||||||
|
|
||||||
|
检查代码:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
deno task check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 打包 exe
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
deno task compile
|
||||||
|
```
|
||||||
|
|
||||||
|
输出文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/media-server.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
打包时会把 `public/` 页面资源嵌入 exe。分发给别人时只需要这个 exe 文件。
|
||||||
|
|
||||||
|
运行 exe:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dist\media-server.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
指定端口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dist\media-server.exe --port 8081
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
1. 主机运行服务。
|
||||||
|
2. 主机打开 `http://localhost:8080/`,点击“选择桌面或应用”。
|
||||||
|
3. 客机在同一局域网访问 `http://<主机IP>:8080/viewer`。
|
||||||
|
4. 如果 Windows 防火墙弹窗,允许当前网络访问。
|
||||||
|
|
||||||
|
主机分享页建议从 `localhost` 打开。浏览器只允许在安全上下文中调用屏幕分享,`localhost` 属于浏览器允许的安全上下文。客机观看页不需要调用屏幕分享,可以用局域网 IP 访问。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 端口被占用
|
||||||
|
|
||||||
|
如果启动时看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Port 8080 is already in use.
|
||||||
|
```
|
||||||
|
|
||||||
|
说明当前端口已经被其他程序占用。可以换端口启动:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dist\media-server.exe --port 8081
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以关闭占用端口的进程后再启动。
|
||||||
|
|
||||||
|
### 客机打不开页面
|
||||||
|
|
||||||
|
- 确认主机和客机在同一局域网。
|
||||||
|
- 确认访问的是主机局域网 IP,不是 `localhost`。
|
||||||
|
- 确认 Windows 防火墙允许该程序访问当前网络。
|
||||||
|
- 确认端口没有被路由器、杀毒软件或公司网络策略拦截。
|
||||||
|
|
||||||
|
### 跨公网访问
|
||||||
|
|
||||||
|
局域网内通常不需要额外服务。跨公网或复杂 NAT 时,WebRTC 可能需要 TURN 服务,本项目默认只配置了公共 STUN。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── deno.json # Deno 任务和编译配置
|
||||||
|
├── public/ # 浏览器页面和前端逻辑
|
||||||
|
├── src/main.ts # HTTP 服务和 WebRTC 信令服务
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git 提交建议
|
||||||
|
|
||||||
|
建议提交源码和静态页面,不提交构建产物:
|
||||||
|
|
||||||
|
```text
|
||||||
|
deno.json
|
||||||
|
README.md
|
||||||
|
.gitignore
|
||||||
|
src/
|
||||||
|
public/
|
||||||
|
```
|
||||||
|
|
||||||
|
`dist/`、`media/`、本地缓存和日志已经在 `.gitignore` 中忽略。
|
||||||
11
deno.json
Normal file
11
deno.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"start": "deno run -A src/main.ts",
|
||||||
|
"check": "deno check src/main.ts",
|
||||||
|
"compile": "deno compile -A --include public --output dist/media-server.exe src/main.ts"
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["deno.ns", "dom", "dom.iterable", "esnext"],
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
33
public/host.html
Normal file
33
public/host.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>桌面分享</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="panel broadcaster">
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Host</p>
|
||||||
|
<h1>桌面分享</h1>
|
||||||
|
</div>
|
||||||
|
<span id="state" class="badge idle">未推流</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<video id="preview" autoplay muted playsinline></video>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="start" type="button">选择桌面或应用</button>
|
||||||
|
<button id="stop" class="secondary" type="button" disabled>停止</button>
|
||||||
|
<a class="secondary link-button" href="/viewer" target="_blank" rel="noreferrer">打开观看页</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="message" class="message">选择一个屏幕、窗口或浏览器标签页后,客机访问本机端口的 /viewer 即可观看。</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/host.js?v=12" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
168
public/host.js
Normal file
168
public/host.js
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
const startButton = document.querySelector("#start");
|
||||||
|
const stopButton = document.querySelector("#stop");
|
||||||
|
const preview = document.querySelector("#preview");
|
||||||
|
const state = document.querySelector("#state");
|
||||||
|
const message = document.querySelector("#message");
|
||||||
|
|
||||||
|
const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
|
||||||
|
|
||||||
|
let stream;
|
||||||
|
let signal;
|
||||||
|
const peers = new Map();
|
||||||
|
|
||||||
|
startButton.addEventListener("click", startSharing);
|
||||||
|
stopButton.addEventListener("click", stopSharing);
|
||||||
|
|
||||||
|
async function startSharing() {
|
||||||
|
console.info("[host] startSharing clicked");
|
||||||
|
startButton.disabled = true;
|
||||||
|
setState("connecting", "准备中");
|
||||||
|
message.textContent = "请选择要分享的桌面、窗口或应用。";
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
|
video: { frameRate: 30 },
|
||||||
|
audio: {
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
sampleRate: 48000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info("[host] getDisplayMedia resolved", describeStream(stream));
|
||||||
|
preview.srcObject = stream;
|
||||||
|
signal = openSignalSocket();
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => {
|
||||||
|
track.addEventListener("ended", () => {
|
||||||
|
console.info("[host] track ended", describeTrack(track));
|
||||||
|
stopSharing();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stopButton.disabled = false;
|
||||||
|
setState("live", "推流中");
|
||||||
|
message.textContent = `观看地址:${location.origin}/viewer`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[host] startSharing failed", error);
|
||||||
|
stopSharing();
|
||||||
|
message.textContent = error instanceof Error ? error.message : "启动分享失败。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSignalSocket() {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=host`);
|
||||||
|
|
||||||
|
nextSocket.addEventListener("open", () => console.info("[host] signal open"));
|
||||||
|
nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data)));
|
||||||
|
nextSocket.addEventListener("close", (event) => console.info("[host] signal close", { code: event.code, reason: event.reason }));
|
||||||
|
nextSocket.addEventListener("error", (event) => console.error("[host] signal error", event));
|
||||||
|
|
||||||
|
return nextSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignalMessage(data) {
|
||||||
|
console.info("[host] signal", data);
|
||||||
|
|
||||||
|
if (data.type === "viewer-join" && typeof data.viewerId === "string") {
|
||||||
|
await createPeer(data.viewerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "answer" && typeof data.viewerId === "string") {
|
||||||
|
const peer = peers.get(data.viewerId);
|
||||||
|
if (peer) {
|
||||||
|
await peer.setRemoteDescription(data.sdp);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "candidate" && typeof data.viewerId === "string") {
|
||||||
|
const peer = peers.get(data.viewerId);
|
||||||
|
if (peer && data.candidate) {
|
||||||
|
await peer.addIceCandidate(data.candidate);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "viewer-left" && typeof data.viewerId === "string") {
|
||||||
|
closePeer(data.viewerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPeer(viewerId) {
|
||||||
|
closePeer(viewerId);
|
||||||
|
|
||||||
|
const peer = new RTCPeerConnection(peerConfig);
|
||||||
|
peers.set(viewerId, peer);
|
||||||
|
|
||||||
|
stream.getTracks().forEach((track) => peer.addTrack(track, stream));
|
||||||
|
peer.addEventListener("icecandidate", (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
sendSignal({ type: "candidate", to: viewerId, candidate: event.candidate });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
peer.addEventListener("connectionstatechange", () => {
|
||||||
|
console.info("[host] peer state", { viewerId, state: peer.connectionState });
|
||||||
|
if (["failed", "closed", "disconnected"].includes(peer.connectionState)) {
|
||||||
|
closePeer(viewerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const offer = await peer.createOffer();
|
||||||
|
await peer.setLocalDescription(offer);
|
||||||
|
sendSignal({ type: "offer", to: viewerId, sdp: peer.localDescription });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSignal(data) {
|
||||||
|
if (signal?.readyState === WebSocket.OPEN) {
|
||||||
|
signal.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePeer(viewerId) {
|
||||||
|
const peer = peers.get(viewerId);
|
||||||
|
if (peer) {
|
||||||
|
peer.close();
|
||||||
|
peers.delete(viewerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSharing() {
|
||||||
|
console.info("[host] stopSharing", { peers: peers.size, signalState: signal?.readyState });
|
||||||
|
for (const viewerId of peers.keys()) {
|
||||||
|
closePeer(viewerId);
|
||||||
|
}
|
||||||
|
signal?.close();
|
||||||
|
stream?.getTracks().forEach((track) => track.stop());
|
||||||
|
|
||||||
|
signal = undefined;
|
||||||
|
stream = undefined;
|
||||||
|
preview.srcObject = null;
|
||||||
|
startButton.disabled = false;
|
||||||
|
stopButton.disabled = true;
|
||||||
|
setState("idle", "未推流");
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeStream(nextStream) {
|
||||||
|
return {
|
||||||
|
tracks: nextStream.getTracks().map(describeTrack),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTrack(track) {
|
||||||
|
return {
|
||||||
|
kind: track.kind,
|
||||||
|
label: track.label,
|
||||||
|
enabled: track.enabled,
|
||||||
|
muted: track.muted,
|
||||||
|
readyState: track.readyState,
|
||||||
|
settings: track.getSettings(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(kind, text) {
|
||||||
|
state.className = `badge ${kind}`;
|
||||||
|
state.textContent = text;
|
||||||
|
}
|
||||||
182
public/styles.css
Normal file
182
public/styles.css
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||||
|
background: #eef3f1;
|
||||||
|
color: #16211f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(23, 86, 77, 0.12), transparent 38%),
|
||||||
|
linear-gradient(315deg, rgba(222, 128, 74, 0.16), transparent 44%),
|
||||||
|
#eef3f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: min(1040px, 100%);
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(28, 48, 44, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow: 0 24px 70px rgba(41, 57, 52, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #66736f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 5vw, 54px);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-stage {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #101817;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
min-width: 132px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-overlay[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.link-button {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid #1f5f55;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1f5f55;
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 42px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
border-color: rgba(31, 95, 85, 0.28);
|
||||||
|
background: #fff;
|
||||||
|
color: #1f5f55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid rgba(31, 95, 85, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: #40504c;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle {
|
||||||
|
background: #e5e9e7;
|
||||||
|
color: #576560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connecting {
|
||||||
|
background: #fff2d7;
|
||||||
|
color: #8c5814;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live {
|
||||||
|
background: #dff4e8;
|
||||||
|
color: #17613b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: #5f6c68;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.shell {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.link-button,
|
||||||
|
.metric {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
public/viewer.html
Normal file
37
public/viewer.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>观看桌面流</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
<section class="panel viewer">
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Viewer</p>
|
||||||
|
<h1>观看桌面流</h1>
|
||||||
|
</div>
|
||||||
|
<span id="state" class="badge idle">等待直播</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-stage">
|
||||||
|
<video id="player" autoplay muted playsinline></video>
|
||||||
|
<button id="play" class="play-overlay" type="button" hidden>播放直播</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="audio" type="button">开启声音</button>
|
||||||
|
<button id="go-live" type="button">回到直播</button>
|
||||||
|
<button id="reload" type="button">重新连接</button>
|
||||||
|
<span id="latency" class="metric">延迟 -- ms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="message" class="message">如果画面未出现,请确认主机已经点击分享并允许浏览器采集屏幕。</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/viewer.js?v=12" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
209
public/viewer.js
Normal file
209
public/viewer.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
const player = document.querySelector("#player");
|
||||||
|
const playButton = document.querySelector("#play");
|
||||||
|
const audioButton = document.querySelector("#audio");
|
||||||
|
const reloadButton = document.querySelector("#reload");
|
||||||
|
const goLiveButton = document.querySelector("#go-live");
|
||||||
|
const state = document.querySelector("#state");
|
||||||
|
const message = document.querySelector("#message");
|
||||||
|
const latency = document.querySelector("#latency");
|
||||||
|
|
||||||
|
const peerConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
|
||||||
|
|
||||||
|
let signal;
|
||||||
|
let peer;
|
||||||
|
let viewerId = crypto.randomUUID();
|
||||||
|
let retryTimer;
|
||||||
|
let playPromise;
|
||||||
|
let remoteStream;
|
||||||
|
|
||||||
|
player.muted = true;
|
||||||
|
player.defaultMuted = true;
|
||||||
|
|
||||||
|
audioButton.disabled = true;
|
||||||
|
audioButton.textContent = "等待声音";
|
||||||
|
|
||||||
|
playButton.addEventListener("click", () => void playWhenReady());
|
||||||
|
audioButton.addEventListener("click", () => {
|
||||||
|
player.muted = !player.muted;
|
||||||
|
audioButton.textContent = player.muted ? "开启声音" : "关闭声音";
|
||||||
|
void playWhenReady();
|
||||||
|
});
|
||||||
|
reloadButton.addEventListener("click", connect);
|
||||||
|
goLiveButton.addEventListener("click", () => void playWhenReady());
|
||||||
|
|
||||||
|
player.addEventListener("playing", () => {
|
||||||
|
console.info("[viewer] media playing");
|
||||||
|
setPlayButtonVisible(false);
|
||||||
|
});
|
||||||
|
player.addEventListener("pause", () => {
|
||||||
|
console.info("[viewer] media pause");
|
||||||
|
setPlayButtonVisible(true);
|
||||||
|
});
|
||||||
|
player.addEventListener("waiting", () => console.info("[viewer] media waiting"));
|
||||||
|
player.addEventListener("error", () => {
|
||||||
|
console.error("[viewer] media error", player.error);
|
||||||
|
setPlayButtonVisible(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
console.info("[viewer] connect start");
|
||||||
|
destroyConnection();
|
||||||
|
setState("connecting", "连接中");
|
||||||
|
latency.textContent = "WebRTC 连接中";
|
||||||
|
message.textContent = "正在连接主机。";
|
||||||
|
|
||||||
|
const status = await fetch("/api/debug").then((response) => response.json());
|
||||||
|
console.info("[viewer] status", status);
|
||||||
|
if (!status.hasHostSignal) {
|
||||||
|
setState("idle", "等待直播");
|
||||||
|
latency.textContent = "等待主机";
|
||||||
|
message.textContent = "主机还没有开始分享。";
|
||||||
|
retryTimer = window.setTimeout(connect, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal = openSignalSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSignalSocket() {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const nextSocket = new WebSocket(`${protocol}://${location.host}/signal?role=viewer&viewerId=${viewerId}`);
|
||||||
|
|
||||||
|
nextSocket.addEventListener("open", () => console.info("[viewer] signal open"));
|
||||||
|
nextSocket.addEventListener("message", (event) => void handleSignalMessage(JSON.parse(event.data)));
|
||||||
|
nextSocket.addEventListener("close", (event) => {
|
||||||
|
console.info("[viewer] signal close", { code: event.code, reason: event.reason });
|
||||||
|
setState("idle", "直播断开");
|
||||||
|
latency.textContent = "连接断开";
|
||||||
|
retryTimer = window.setTimeout(connect, 1000);
|
||||||
|
});
|
||||||
|
nextSocket.addEventListener("error", (event) => {
|
||||||
|
console.error("[viewer] signal error", event);
|
||||||
|
setState("idle", "连接错误");
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignalMessage(data) {
|
||||||
|
console.info("[viewer] signal", data);
|
||||||
|
|
||||||
|
if (data.type === "ready" && typeof data.viewerId === "string") {
|
||||||
|
viewerId = data.viewerId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "host-left") {
|
||||||
|
setState("idle", "直播结束");
|
||||||
|
latency.textContent = "主机已断开";
|
||||||
|
message.textContent = "主机已停止分享。";
|
||||||
|
destroyPeer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "offer" && data.sdp) {
|
||||||
|
await acceptOffer(data.sdp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === "candidate" && data.candidate && peer) {
|
||||||
|
await peer.addIceCandidate(data.candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptOffer(offer) {
|
||||||
|
destroyPeer();
|
||||||
|
peer = new RTCPeerConnection(peerConfig);
|
||||||
|
remoteStream = new MediaStream();
|
||||||
|
player.srcObject = remoteStream;
|
||||||
|
|
||||||
|
peer.addEventListener("track", (event) => {
|
||||||
|
console.info("[viewer] track", { kind: event.track.kind, label: event.track.label });
|
||||||
|
remoteStream.addTrack(event.track);
|
||||||
|
if (event.track.kind === "audio") {
|
||||||
|
audioButton.disabled = false;
|
||||||
|
audioButton.textContent = player.muted ? "开启声音" : "关闭声音";
|
||||||
|
}
|
||||||
|
void playWhenReady();
|
||||||
|
});
|
||||||
|
peer.addEventListener("icecandidate", (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
sendSignal({ type: "candidate", candidate: event.candidate });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
peer.addEventListener("connectionstatechange", () => {
|
||||||
|
console.info("[viewer] peer state", peer.connectionState);
|
||||||
|
latency.textContent = `WebRTC ${peer.connectionState}`;
|
||||||
|
if (peer.connectionState === "connected") {
|
||||||
|
setState("live", "播放中");
|
||||||
|
message.textContent = "正在播放主机分享的桌面。";
|
||||||
|
}
|
||||||
|
if (["failed", "closed", "disconnected"].includes(peer.connectionState)) {
|
||||||
|
setState("idle", "直播断开");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await peer.setRemoteDescription(offer);
|
||||||
|
const answer = await peer.createAnswer();
|
||||||
|
await peer.setLocalDescription(answer);
|
||||||
|
sendSignal({ type: "answer", sdp: peer.localDescription });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSignal(data) {
|
||||||
|
if (signal?.readyState === WebSocket.OPEN) {
|
||||||
|
signal.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playWhenReady() {
|
||||||
|
if (playPromise) {
|
||||||
|
return playPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.info("[viewer] calling player.play", { readyState: player.readyState, muted: player.muted });
|
||||||
|
playPromise = player.play();
|
||||||
|
await playPromise;
|
||||||
|
setPlayButtonVisible(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[viewer] player.play rejected", error);
|
||||||
|
message.textContent = "浏览器阻止了自动播放,请点击画面上的播放直播。";
|
||||||
|
setPlayButtonVisible(true);
|
||||||
|
} finally {
|
||||||
|
playPromise = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyConnection() {
|
||||||
|
console.info("[viewer] destroyConnection");
|
||||||
|
window.clearTimeout(retryTimer);
|
||||||
|
retryTimer = undefined;
|
||||||
|
signal?.close();
|
||||||
|
signal = undefined;
|
||||||
|
destroyPeer();
|
||||||
|
setPlayButtonVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyPeer() {
|
||||||
|
peer?.close();
|
||||||
|
peer = undefined;
|
||||||
|
remoteStream?.getTracks().forEach((track) => track.stop());
|
||||||
|
remoteStream = undefined;
|
||||||
|
playPromise = undefined;
|
||||||
|
player.removeAttribute("src");
|
||||||
|
player.srcObject = null;
|
||||||
|
player.load();
|
||||||
|
audioButton.disabled = true;
|
||||||
|
audioButton.textContent = "等待声音";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPlayButtonVisible(visible) {
|
||||||
|
playButton.hidden = !visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(kind, text) {
|
||||||
|
state.className = `badge ${kind}`;
|
||||||
|
state.textContent = text;
|
||||||
|
}
|
||||||
564
src/main.ts
Normal file
564
src/main.ts
Normal file
@ -0,0 +1,564 @@
|
|||||||
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
|
import { dirname, isAbsolute, join, normalize, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
type CliOptions = {
|
||||||
|
webPort: number;
|
||||||
|
rtmpPort: number;
|
||||||
|
nmsHttpPort: number;
|
||||||
|
streamKey: string;
|
||||||
|
ffmpegPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StreamSession = {
|
||||||
|
startedAt: number;
|
||||||
|
chunks: number;
|
||||||
|
bytes: number;
|
||||||
|
lastChunkSentAt?: number;
|
||||||
|
lastChunkReceivedAt?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
initChunks: RelayChunk[];
|
||||||
|
recentChunks: RelayChunk[];
|
||||||
|
nextChunkId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RelayChunk = {
|
||||||
|
id: number;
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxRecentChunks = 60;
|
||||||
|
|
||||||
|
const rootDir = normalize(join(dirname(fileURLToPath(import.meta.url)), ".."));
|
||||||
|
const publicDir = join(rootDir, "public");
|
||||||
|
const mediaRoot = join(rootDir, "media");
|
||||||
|
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".m3u8": "application/vnd.apple.mpegurl",
|
||||||
|
".ts": "video/mp2t",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = parseArgs(Deno.args);
|
||||||
|
clearStreamFiles(options.streamKey);
|
||||||
|
|
||||||
|
let currentSession: StreamSession | undefined;
|
||||||
|
const viewers = new Set<WebSocket>();
|
||||||
|
let hostSignal: WebSocket | undefined;
|
||||||
|
const signalViewers = new Map<string, WebSocket>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Deno.serve({ hostname: "0.0.0.0", port: options.webPort }, handleRequest);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Deno.errors.AddrInUse) {
|
||||||
|
console.error(`Port ${options.webPort} is already in use.`);
|
||||||
|
console.error(`Try another port: media-server.exe --port ${options.webPort + 1}`);
|
||||||
|
console.error(`Or close the process currently using port ${options.webPort}.`);
|
||||||
|
Deno.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/ingest") {
|
||||||
|
return handleIngest(request, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/watch") {
|
||||||
|
return handleWatch(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/signal") {
|
||||||
|
return handleSignal(request, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/status") {
|
||||||
|
return jsonResponse({
|
||||||
|
publishing: currentSession !== undefined,
|
||||||
|
hlsPath: `/hls/live/${options.streamKey}/index.m3u8`,
|
||||||
|
streamKey: options.streamKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/api/debug") {
|
||||||
|
return jsonResponse(getDebugState(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/hls/")) {
|
||||||
|
return serveFile(mediaRoot, url.pathname.replace(/^\/hls\//, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
return serveFile(publicDir, "host.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/viewer") {
|
||||||
|
return serveFile(publicDir, "viewer.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
return serveFile(publicDir, url.pathname.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Host page: http://localhost:${options.webPort}/`);
|
||||||
|
console.log(`Viewer page: http://localhost:${options.webPort}/viewer`);
|
||||||
|
console.log(`LAN access: http://<this-computer-ip>:${options.webPort}/viewer`);
|
||||||
|
console.log("Stream mode: WebRTC with WebSocket signaling");
|
||||||
|
|
||||||
|
function handleSignal(request: Request, url: URL): Response {
|
||||||
|
if (request.headers.get("upgrade") !== "websocket") {
|
||||||
|
return new Response("Expected websocket", { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = url.searchParams.get("role");
|
||||||
|
const viewerId = url.searchParams.get("viewerId") ?? crypto.randomUUID();
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
if (role === "host") {
|
||||||
|
hostSignal = socket;
|
||||||
|
console.log("[signal] host connected");
|
||||||
|
socket.send(JSON.stringify({ type: "ready" }));
|
||||||
|
for (const id of signalViewers.keys()) {
|
||||||
|
socket.send(JSON.stringify({ type: "viewer-join", viewerId: id }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signalViewers.set(viewerId, socket);
|
||||||
|
console.log(`[signal] viewer connected id=${viewerId} count=${signalViewers.size}`);
|
||||||
|
socket.send(JSON.stringify({ type: "ready", viewerId }));
|
||||||
|
hostSignal?.send(JSON.stringify({ type: "viewer-join", viewerId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (typeof event.data !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = parseJsonMessage(event.data);
|
||||||
|
if (!message?.type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "host") {
|
||||||
|
const target = typeof message.to === "string" ? signalViewers.get(message.to) : undefined;
|
||||||
|
target?.send(JSON.stringify(message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hostSignal?.send(JSON.stringify({ ...message, viewerId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (role === "host") {
|
||||||
|
if (hostSignal === socket) {
|
||||||
|
hostSignal = undefined;
|
||||||
|
}
|
||||||
|
console.log("[signal] host disconnected");
|
||||||
|
broadcastSignalViewers({ type: "host-left" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signalViewers.delete(viewerId);
|
||||||
|
console.log(`[signal] viewer disconnected id=${viewerId} count=${signalViewers.size}`);
|
||||||
|
hostSignal?.send(JSON.stringify({ type: "viewer-left", viewerId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastSignalViewers(message: unknown): void {
|
||||||
|
const text = JSON.stringify(message);
|
||||||
|
for (const viewer of signalViewers.values()) {
|
||||||
|
if (viewer.readyState === WebSocket.OPEN) {
|
||||||
|
viewer.send(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIngest(request: Request, cliOptions: CliOptions): Promise<Response> {
|
||||||
|
if (request.headers.get("upgrade") !== "websocket") {
|
||||||
|
return new Response("Expected websocket", { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSession) {
|
||||||
|
return new Response("Another host is already publishing", { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
let session: StreamSession | undefined;
|
||||||
|
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.onopen = () => {
|
||||||
|
console.log("[ingest] websocket opened");
|
||||||
|
session = {
|
||||||
|
startedAt: Date.now(),
|
||||||
|
chunks: 0,
|
||||||
|
bytes: 0,
|
||||||
|
initChunks: [],
|
||||||
|
recentChunks: [],
|
||||||
|
nextChunkId: 1,
|
||||||
|
};
|
||||||
|
currentSession = session;
|
||||||
|
socket.send(JSON.stringify({ type: "ready" }));
|
||||||
|
};
|
||||||
|
socket.onmessage = async (event) => {
|
||||||
|
if (typeof event.data === "string" && session) {
|
||||||
|
const message = parseJsonMessage(event.data);
|
||||||
|
if (message?.type === "start" && typeof message.mimeType === "string") {
|
||||||
|
session.mimeType = message.mimeType;
|
||||||
|
console.log(`[relay] stream mime=${session.mimeType}`);
|
||||||
|
broadcastJson({ type: "stream", mimeType: session.mimeType });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(event.data instanceof ArrayBuffer) || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const framedChunk = new Uint8Array(event.data);
|
||||||
|
const sentAt = readChunkTimestamp(framedChunk);
|
||||||
|
const chunk = framedChunk.slice(8);
|
||||||
|
if (chunk.byteLength < 256) {
|
||||||
|
console.log(`[relay] skipped tiny chunk bytes=${chunk.byteLength}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.chunks += 1;
|
||||||
|
session.bytes += chunk.byteLength;
|
||||||
|
session.lastChunkSentAt = sentAt;
|
||||||
|
session.lastChunkReceivedAt = Date.now();
|
||||||
|
const relayChunk = { id: session.nextChunkId, data: framedChunk };
|
||||||
|
session.nextChunkId += 1;
|
||||||
|
if (session.initChunks.length < 1) {
|
||||||
|
session.initChunks.push(relayChunk);
|
||||||
|
}
|
||||||
|
session.recentChunks.push(relayChunk);
|
||||||
|
while (session.recentChunks.length > maxRecentChunks) {
|
||||||
|
session.recentChunks.shift();
|
||||||
|
}
|
||||||
|
if (session.chunks === 1 || session.chunks % 40 === 0) {
|
||||||
|
console.log(`[relay] chunk=${session.chunks} bytes=${chunk.byteLength} total=${session.bytes} viewers=${viewers.size}`);
|
||||||
|
}
|
||||||
|
broadcastBinary(framedChunk);
|
||||||
|
};
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
console.log(`[ingest] websocket closed code=${event.code} reason=${event.reason || "<empty>"}`);
|
||||||
|
if (currentSession === session) {
|
||||||
|
currentSession = undefined;
|
||||||
|
}
|
||||||
|
broadcastJson({ type: "end" });
|
||||||
|
};
|
||||||
|
socket.onerror = (event) => {
|
||||||
|
console.error("[ingest] websocket error", event);
|
||||||
|
if (currentSession === session) {
|
||||||
|
currentSession = undefined;
|
||||||
|
}
|
||||||
|
broadcastJson({ type: "end" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWatch(request: Request): Response {
|
||||||
|
if (request.headers.get("upgrade") !== "websocket") {
|
||||||
|
return new Response("Expected websocket", { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.onopen = () => {
|
||||||
|
viewers.add(socket);
|
||||||
|
console.log(`[watch] viewer connected count=${viewers.size}`);
|
||||||
|
if (currentSession?.mimeType) {
|
||||||
|
socket.send(JSON.stringify({ type: "stream", mimeType: currentSession.mimeType }));
|
||||||
|
const sentIds = new Set<number>();
|
||||||
|
for (const chunk of currentSession.initChunks) {
|
||||||
|
socket.send(toArrayBuffer(chunk.data));
|
||||||
|
sentIds.add(chunk.id);
|
||||||
|
}
|
||||||
|
for (const chunk of currentSession.recentChunks) {
|
||||||
|
if (!sentIds.has(chunk.id)) {
|
||||||
|
socket.send(toArrayBuffer(chunk.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
socket.send(JSON.stringify({ type: "waiting" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.onclose = () => {
|
||||||
|
viewers.delete(socket);
|
||||||
|
console.log(`[watch] viewer disconnected count=${viewers.size}`);
|
||||||
|
};
|
||||||
|
socket.onerror = () => {
|
||||||
|
viewers.delete(socket);
|
||||||
|
console.log(`[watch] viewer error count=${viewers.size}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStreamFiles(streamKey: string): void {
|
||||||
|
const streamDir = join(mediaRoot, "live", streamKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Deno.removeSync(streamDir, { recursive: true });
|
||||||
|
console.log(`[hls] cleared ${streamDir}`);
|
||||||
|
} catch {
|
||||||
|
// Old HLS files are optional; the directory may not exist on first run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDebugState(cliOptions: CliOptions): Record<string, unknown> {
|
||||||
|
const streamDir = join(mediaRoot, "live", cliOptions.streamKey);
|
||||||
|
const hlsPath = join(streamDir, "index.m3u8");
|
||||||
|
|
||||||
|
return {
|
||||||
|
publishing: currentSession !== undefined,
|
||||||
|
viewers: viewers.size,
|
||||||
|
signalViewers: signalViewers.size,
|
||||||
|
hasHostSignal: hostSignal?.readyState === WebSocket.OPEN,
|
||||||
|
mode: "webrtc-signaling",
|
||||||
|
session: currentSession
|
||||||
|
? {
|
||||||
|
chunks: currentSession.chunks,
|
||||||
|
bytes: currentSession.bytes,
|
||||||
|
ageMs: Date.now() - currentSession.startedAt,
|
||||||
|
mimeType: currentSession.mimeType,
|
||||||
|
bufferedChunks: currentSession.recentChunks.length,
|
||||||
|
lastChunkSentAt: currentSession.lastChunkSentAt,
|
||||||
|
lastChunkReceivedAt: currentSession.lastChunkReceivedAt,
|
||||||
|
lastRelayDelayMs: currentSession.lastChunkSentAt && currentSession.lastChunkReceivedAt
|
||||||
|
? currentSession.lastChunkReceivedAt - currentSession.lastChunkSentAt
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
ffmpegPath: cliOptions.ffmpegPath,
|
||||||
|
hlsPath,
|
||||||
|
files: listFiles(streamDir),
|
||||||
|
manifest: readTextFile(hlsPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFiles(dir: string): Array<Record<string, unknown>> {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return readdirSync(dir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => {
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
const stat = Deno.statSync(path);
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
size: stat.size,
|
||||||
|
modified: stat.mtime?.toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTextFile(path: string): string | undefined {
|
||||||
|
try {
|
||||||
|
return new TextDecoder().decode(Deno.readFileSync(path));
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logHlsState(hlsPath: string): void {
|
||||||
|
const dir = dirname(hlsPath);
|
||||||
|
const files = listFiles(dir).map((file) => `${String(file.name)}:${String(file.size)}`).join(", ");
|
||||||
|
console.log(`[hls] files=${files || "<none>"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logReadableStream(prefix: string, stream: ReadableStream<Uint8Array>): Promise<void> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const text = decoder.decode(value, { stream: true }).trim();
|
||||||
|
if (text) {
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
console.log(`[${prefix}] ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[${prefix}] failed to read stderr`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveFile(baseDir: string, relativePath: string): Promise<Response> {
|
||||||
|
const safeRelativePath = relativePath === "" ? "host.html" : relativePath;
|
||||||
|
const filePath = normalize(join(baseDir, safeRelativePath));
|
||||||
|
const normalizedBase = normalize(baseDir);
|
||||||
|
const relativeToBase = relative(normalizedBase, filePath);
|
||||||
|
|
||||||
|
if (relativeToBase.startsWith("..") || isAbsolute(relativeToBase) || !existsSync(filePath)) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await Deno.readFile(filePath);
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"content-type": mimeTypes[getExtension(filePath)] ?? "application/octet-stream",
|
||||||
|
"cache-control": filePath.endsWith(".m3u8") ? "no-cache" : "public, max-age=2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(args: string[]): CliOptions {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
||||||
|
const value = inlineValue ?? args[index + 1];
|
||||||
|
if (inlineValue === undefined) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
values.set(rawKey, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
webPort: readPort(values.get("port"), 8080, "port"),
|
||||||
|
rtmpPort: readPort(values.get("rtmp-port"), 1935, "rtmp-port"),
|
||||||
|
nmsHttpPort: readPort(values.get("nms-http-port"), 8001, "nms-http-port"),
|
||||||
|
streamKey: values.get("stream-key") ?? "desktop",
|
||||||
|
ffmpegPath: values.get("ffmpeg") ?? "ffmpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPort(value: string | undefined, fallback: number, name: string): number {
|
||||||
|
const port = value === undefined ? fallback : Number(value);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error(`--${name} 必须是 1 到 65535 之间的端口号`);
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCommand(command: string, args: string[], message: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const status = await new Deno.Command(command, { args, stdout: "null", stderr: "null" }).spawn().status;
|
||||||
|
if (!status.success) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveFfmpegPath(candidate: string): Promise<string> {
|
||||||
|
if (candidate !== "ffmpeg" || await commandExists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localAppData = Deno.env.get("LOCALAPPDATA");
|
||||||
|
if (!localAppData) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wingetPackages = join(localAppData, "Microsoft", "WinGet", "Packages");
|
||||||
|
return findFile(wingetPackages, "ffmpeg.exe", 6) ?? candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commandExists(command: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const status = await new Deno.Command(command, { args: ["-version"], stdout: "null", stderr: "null" }).spawn().status;
|
||||||
|
return status.success;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFile(root: string, filename: string, maxDepth: number): string | undefined {
|
||||||
|
if (maxDepth < 0 || !existsSync(root)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||||
|
const entryPath = join(root, entry.name);
|
||||||
|
if (entry.isFile() && entry.name.toLowerCase() === filename.toLowerCase()) {
|
||||||
|
return entryPath;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const found = findFile(entryPath, filename, maxDepth - 1);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(data: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { "content-type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtension(path: string): string {
|
||||||
|
const index = path.lastIndexOf(".");
|
||||||
|
return index === -1 ? "" : path.slice(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastJson(data: unknown): void {
|
||||||
|
const text = JSON.stringify(data);
|
||||||
|
for (const viewer of viewers) {
|
||||||
|
if (viewer.readyState === WebSocket.OPEN) {
|
||||||
|
viewer.send(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastBinary(chunk: Uint8Array): void {
|
||||||
|
const payload = toArrayBuffer(chunk);
|
||||||
|
for (const viewer of viewers) {
|
||||||
|
if (viewer.readyState === WebSocket.OPEN) {
|
||||||
|
viewer.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChunkTimestamp(chunk: Uint8Array): number | undefined {
|
||||||
|
if (chunk.byteLength < 8) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(new DataView(chunk.buffer, chunk.byteOffset, chunk.byteLength).getBigUint64(0, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(chunk: Uint8Array): ArrayBuffer {
|
||||||
|
return chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength) as ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonMessage(text: string): Record<string, unknown> | undefined {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(text);
|
||||||
|
return typeof value === "object" && value !== null ? value as Record<string, unknown> : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user