feat:移除直播模块

This commit is contained in:
M了个J 2026-05-12 20:08:56 +08:00
parent 4805c7ee35
commit a1b0764e99
8 changed files with 13 additions and 744 deletions

View File

@ -43,7 +43,6 @@
"lottie-web": "^5.12.2",
"markdown-it": "^14.1.0",
"mathjax": "3.0.1",
"mediasoup-client": "^3.20.0",
"mermaid-it-markdown": "^1.0.8",
"nprogress": "^0.2.0",
"pinia": "^2.2.0",

99
pnpm-lock.yaml generated
View File

@ -92,9 +92,6 @@ importers:
mathjax:
specifier: 3.0.1
version: 3.0.1
mediasoup-client:
specifier: ^3.20.0
version: 3.20.0
mermaid-it-markdown:
specifier: ^1.0.8
version: 1.0.8(mermaid@10.9.3)(typescript@5.8.2)
@ -658,14 +655,6 @@ packages:
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@microsoft/fetch-event-source@2.0.1':
resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==}
@ -917,18 +906,12 @@ packages:
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
'@types/events@3.0.3':
resolution: {integrity: sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==}
'@types/hammerjs@2.0.46':
resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==}
@ -1358,10 +1341,6 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
awaitqueue@3.3.0:
resolution: {integrity: sha512-zLxDhzQbzHmOyvxi7g3OlfR7jLrcmElStPxfLPpJkrFSDm71RSrY/MvsDA8Btlx8X1nOHUzGhQvc6bdUjL2f2w==}
engines: {node: '>=20'}
axios@1.8.4:
resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==}
@ -2108,10 +2087,6 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
@ -2122,10 +2097,6 @@ packages:
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
fake-mediastreamtrack@2.2.1:
resolution: {integrity: sha512-SITLc7UTDirSdgLGORrlmqjWLJtbtfIz/xO7DwVbJH3f0ds+NQok4ccl/WEzz49NSgiUlXf2wDW0+y5C+TO6EA==}
engines: {node: '>=20'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@ -2294,10 +2265,6 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
h264-profile-level-id@2.3.2:
resolution: {integrity: sha512-hnq1UDlw7WGJV6GCr/g7wnkHYUjdAY2bis9rgn2JqSdQS2WfVvnt1ZE9g8nTguracodf5LLKZOwURsDN49YtBQ==}
engines: {node: '>=20'}
hammerjs@2.0.8:
resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==}
engines: {node: '>=0.8.0'}
@ -2754,10 +2721,6 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mediasoup-client@3.20.0:
resolution: {integrity: sha512-FL6RlsGwvmXbXkbMoc1eW2EqW7k8Cw/Mhls3QewQXH+mioWVeVNWjzcDPmTZ3Sw+mua/n0IH5C/ntuVsx+KegA==}
engines: {node: '>=22'}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@ -3266,10 +3229,6 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
sdp-transform@3.0.0:
resolution: {integrity: sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==}
hasBin: true
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -3413,10 +3372,6 @@ packages:
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@ -4246,12 +4201,6 @@ snapshots:
'@kurkle/color@0.3.4': {}
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@microsoft/fetch-event-source@2.0.1': {}
'@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
@ -4420,18 +4369,12 @@ snapshots:
'@types/d3-time@3.0.4': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/estree@1.0.7': {}
'@types/events@3.0.3': {}
'@types/hammerjs@2.0.46': {}
'@types/json-schema@7.0.15': {}
@ -4980,12 +4923,6 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
awaitqueue@3.3.0(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
axios@1.8.4:
dependencies:
follow-redirects: 1.15.9
@ -5432,11 +5369,9 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.4.3(supports-color@10.2.2):
debug@4.4.3:
dependencies:
ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
decamelize@1.2.0: {}
@ -5867,8 +5802,6 @@ snapshots:
eventemitter3@5.0.1: {}
events@3.3.0: {}
execa@8.0.1:
dependencies:
cross-spawn: 7.0.6
@ -5887,10 +5820,6 @@ snapshots:
dependencies:
type: 2.7.3
fake-mediastreamtrack@2.2.1:
dependencies:
'@lukeed/uuid': 2.0.1
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@ -6077,12 +6006,6 @@ snapshots:
graphemer@1.4.0: {}
h264-profile-level-id@2.3.2(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
hammerjs@2.0.8: {}
has-bigints@1.1.0: {}
@ -6531,18 +6454,6 @@ snapshots:
mdurl@2.0.0: {}
mediasoup-client@3.20.0:
dependencies:
'@types/debug': 4.1.13
'@types/events-alias': '@types/events@3.0.3'
awaitqueue: 3.3.0(supports-color@10.2.2)
debug: 4.4.3(supports-color@10.2.2)
events-alias: events@3.3.0
fake-mediastreamtrack: 2.2.1
h264-profile-level-id: 2.3.2(supports-color@10.2.2)
sdp-transform: 3.0.0
supports-color: 10.2.2
memoize-one@6.0.0: {}
meow@12.1.1: {}
@ -6703,8 +6614,8 @@ snapshots:
micromark@3.2.0:
dependencies:
'@types/debug': 4.1.12
debug: 4.4.0
'@types/debug': 4.1.13
debug: 4.4.3
decode-named-character-reference: 1.1.0
micromark-core-commonmark: 1.1.0
micromark-factory-space: 1.1.0
@ -7142,8 +7053,6 @@ snapshots:
scule@1.3.0: {}
sdp-transform@3.0.0: {}
semver@6.3.1: {}
semver@7.7.2: {}
@ -7301,8 +7210,6 @@ snapshots:
stylis@4.3.6: {}
supports-color@10.2.2: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0

View File

@ -1,36 +0,0 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = defineProps<{ stream: MediaStream | null }>()
const el = ref<HTMLVideoElement | null>(null)
watch(
() => props.stream,
async s => {
await nextTick()
const v = el.value
if (!v) return
v.srcObject = s
try {
await v.play()
} catch {
/* autoplay policy / transient */
}
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<!-- muted: remote video autoplay is often blocked without muted -->
<video ref="el" class="vid" autoplay playsinline muted />
</template>
<style scoped>
.vid {
width: 100%;
max-height: 70vh;
background: #000;
border-radius: 8px;
}
</style>

View File

@ -1,322 +0,0 @@
<script setup lang="ts">
import StreamVideo from './StreamVideo.vue'
import { RoomWsClient } from './room-ws-client'
import type { Transport } from 'mediasoup-client/lib/types'
const roomId = ref('classroom')
const displayName = ref('学员')
const wsHost = ref('127.0.0.1')
const wsPort = ref(30443)
const status = ref('未连接')
const teacherStream = ref<MediaStream | null>(null)
const localPreview = ref<MediaStream | null>(null)
let client: RoomWsClient | null = null
let unsub: (() => void) | null = null
let sendTransport: Transport | null = null
let recvTransport: Transport | null = null
let teacherPeerId: string | null = null
const consumed = new Set<string>()
const pendingTeacherProducerIds: string[] = []
function wsUrl() {
return `ws://${wsHost.value}:${wsPort.value}`
}
function resetAfterRoomGone() {
teacherStream.value = null
recvTransport = null
sendTransport = null
teacherPeerId = null
consumed.clear()
pendingTeacherProducerIds.length = 0
localPreview.value?.getTracks().forEach(t => t.stop())
localPreview.value = null
unsub?.()
unsub = null
client?.close()
client = null
}
onMounted(() => {
try {
const q = new URLSearchParams(window.location.search)
const hp = q.get('port')
const hh = q.get('host')
if (hh?.trim()) wsHost.value = hh.trim()
if (hp) {
const n = Number(hp)
if (Number.isFinite(n) && n > 0) wsPort.value = n
}
} catch {
/* ignore */
}
})
async function consumeTeacher(recv: Transport, producerId: string) {
if (consumed.has(producerId)) return
if (!client) return
try {
const consumer = await client.consume(recv, producerId)
consumed.add(producerId)
teacherStream.value = new MediaStream([consumer.track])
consumer.on('producerclose', () => {
consumed.delete(producerId)
teacherStream.value = null
})
} catch (e) {
status.value = `拉取教师画面失败:${e instanceof Error ? e.message : String(e)}`
}
}
async function joinClass() {
teacherStream.value = null
localPreview.value?.getTracks().forEach(t => t.stop())
localPreview.value = null
consumed.clear()
pendingTeacherProducerIds.length = 0
unsub?.()
client?.close()
client = null
sendTransport = null
recvTransport = null
teacherPeerId = null
status.value = '连接信令…'
client = new RoomWsClient(wsUrl())
client.onDisconnect(() => {
status.value = '与教室断开,请重新点击「加入课堂」'
resetAfterRoomGone()
})
try {
await client.connect()
unsub = client.subscribe(msg => {
if (msg.type === 'newProducer' && msg.data) {
const d = msg.data as {
peerId: string
producerId: string
appData?: { role?: string }
}
if (d.appData?.role === 'teacher') {
teacherPeerId = d.peerId
if (recvTransport) void consumeTeacher(recvTransport, d.producerId)
else if (!pendingTeacherProducerIds.includes(d.producerId)) {
pendingTeacherProducerIds.push(d.producerId)
}
}
}
if (msg.type === 'peerLeft' && msg.data) {
const d = msg.data as { peerId: string; role?: string }
const teacherLeft =
d.role === 'teacher' || (teacherPeerId !== null && d.peerId === teacherPeerId)
if (teacherLeft) {
status.value = '未连接(教师端已断开,请重新点击「加入课堂」)'
resetAfterRoomGone()
}
}
if (msg.type === 'peerJoined' && msg.data) {
const d = msg.data as {
peerId: string
role?: string
self?: boolean
}
if (!d.self && d.role === 'teacher') {
teacherPeerId = d.peerId
}
}
if (msg.type === 'producerClosed' && msg.data) {
const d = msg.data as { producerId: string }
consumed.delete(d.producerId)
const i = pendingTeacherProducerIds.indexOf(d.producerId)
if (i !== -1) pendingTeacherProducerIds.splice(i, 1)
teacherStream.value = null
status.value = '教师已停止共享屏幕;恢复后请重新加入或等待教师再次推流'
}
})
const joined = await client.join(roomId.value, 'student', displayName.value)
await client.loadDevice(joined.rtpCapabilities)
recvTransport = await client.createRecvTransport()
for (const pid of pendingTeacherProducerIds.splice(0)) {
await consumeTeacher(recvTransport, pid)
}
for (const ep of joined.existingProducers) {
if (ep.appData?.role === 'teacher') {
teacherPeerId = ep.peerId
await consumeTeacher(recvTransport, ep.producerId)
}
}
status.value =
'已加入课堂。请点击「共享我的屏幕」将画面推送给教师(手机浏览器上必须点一次该按钮)。'
} catch (e) {
unsub?.()
unsub = null
client?.close()
client = null
recvTransport = null
sendTransport = null
status.value = `加入失败:${e instanceof Error ? e.message : String(e)}`
}
}
async function pushScreenToTeacher(): Promise<boolean> {
if (!client) {
status.value = '请先加入课堂'
return false
}
if (sendTransport) {
status.value = '已在向教师推送屏幕'
return true
}
if (!window.isSecureContext) {
status.value =
'当前页面不是安全上下文(例如用 http://局域网IP 打开)。屏幕共享会被浏览器禁用,请改用 HTTPS 部署,或使用 https://localhost / 本机调试。'
return false
}
const gdm = navigator.mediaDevices?.getDisplayMedia
if (typeof gdm !== 'function') {
status.value =
'当前浏览器不支持屏幕共享(无 getDisplayMedia。请换用 Chrome 等浏览器,或确认系统版本。'
return false
}
status.value = '正在采集屏幕以推送给教师…'
let stream: MediaStream
try {
stream = await gdm.call(navigator.mediaDevices, {
video: true,
audio: false,
})
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
status.value = `未开始共享(${msg})。请直接点击「共享我的屏幕」(不要依赖加入课堂后自动共享);若通过 http 访问局域网地址,需改为 HTTPS。`
return false
}
localPreview.value = stream
stream.getVideoTracks()[0].addEventListener('ended', () => {
sendTransport?.close()
sendTransport = null
localPreview.value?.getTracks().forEach(t => t.stop())
localPreview.value = null
status.value = '本机屏幕共享已结束;仍与课堂保持连接,可再次点击「共享我的屏幕」推送'
})
sendTransport = await client.createSendTransport()
await sendTransport.produce({
track: stream.getVideoTracks()[0],
appData: { source: 'screen' },
})
status.value = '已连接,正在向教师推送本机屏幕'
return true
}
async function shareMyScreen() {
await pushScreenToTeacher()
}
async function leave() {
resetAfterRoomGone()
status.value = '已离开'
}
onUnmounted(() => {
void leave()
})
</script>
<template>
<div class="live-student px-w">
<div class="app">
<header class="bar">
<h1>学员端</h1>
<div class="row">
<label>房间 <input v-model="roomId" /></label>
<label>显示名 <input v-model="displayName" /></label>
<label>WS 主机 <input v-model="wsHost" placeholder="教师机 IP" /></label>
<label>
端口
<input
v-model.number="wsPort"
type="number"
title="须与教师端界面上的端口一致(教师端可能为随机端口)"
/>
</label>
<button type="button" class="primary" @click="joinClass">加入课堂</button>
<button type="button" @click="shareMyScreen">共享我的屏幕</button>
<button type="button" @click="leave">离开</button>
</div>
<p class="status">{{ status }}</p>
</header>
<section class="main">
<h2>教师屏幕</h2>
<StreamVideo v-if="teacherStream" :stream="teacherStream" />
<p v-else class="muted">等待教师推流</p>
</section>
<section class="pip">
<h2>我的屏幕预览</h2>
<StreamVideo v-if="localPreview" :stream="localPreview" />
<p v-else class="muted">未共享</p>
</section>
</div>
</div>
</template>
<style scoped>
.live-student {
min-height: 100vh;
}
.app {
font-family: system-ui, sans-serif;
padding: 12px;
color: #e8e8ef;
background: #0f1419;
min-height: 100vh;
}
.bar h1 {
margin: 0 0 8px;
font-size: 1.25rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.row input {
width: 130px;
}
button {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #444;
background: #252b36;
color: inherit;
cursor: pointer;
}
button.primary {
background: #2f9e6d;
border-color: #2f9e6d;
}
.status {
margin: 8px 0 0;
opacity: 0.85;
font-size: 0.9rem;
}
.main,
.pip {
margin-top: 16px;
}
.main h2,
.pip h2 {
font-size: 1rem;
margin-bottom: 8px;
}
.muted {
opacity: 0.6;
}
.pip .vid {
max-height: 200px;
}
</style>

View File

@ -1,276 +0,0 @@
import * as mediasoupClient from 'mediasoup-client'
import type { Device, Transport, Consumer } from 'mediasoup-client/lib/types'
import type * as T from 'mediasoup-client/lib/types'
export type Role = 'teacher' | 'student'
type Handler = (msg: { type: string; data?: unknown }) => void
export class RoomWsClient {
private ws!: WebSocket
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>()
private seq = 0
private handlers: Handler[] = []
private device: Device | null = null
private onWsClosed: (() => void) | null = null
private voluntaryClose = false
private signalingEverOpened = false
constructor(private readonly url: string) {}
onDisconnect(handler: (() => void) | null) {
this.onWsClosed = handler
}
connect(timeoutMs = 20000): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false
const finish = (fn: () => void) => {
if (settled) return
settled = true
clearTimeout(timer)
fn()
}
this.signalingEverOpened = false
this.ws = new WebSocket(this.url)
const timer = setTimeout(() => {
finish(() => {
this.voluntaryClose = true
try {
this.ws.close()
} catch {
/* ignore */
}
reject(
new Error(
`信令连接超时(${timeoutMs / 1000}s请确认教师端已启动 SFU、端口正确跨设备时「WS 主机」须填教师机局域网 IP勿使用 127.0.0.1`,
),
)
})
}, timeoutMs)
this.ws.addEventListener('open', () => {
this.signalingEverOpened = true
finish(() => resolve())
})
this.ws.addEventListener('error', () => {
finish(() =>
reject(
new Error(
'无法连接信令请核对「端口」与教师端状态栏里「SFU 已监听」的端口一致跨设备时「WS 主机」填教师机局域网 IP勿用 127.0.0.1);并确认教师已启动 SFU、防火墙放行该端口。',
),
),
)
})
this.ws.addEventListener('message', ev => this.handleRaw(ev.data))
this.ws.addEventListener('close', () => this.handleSocketClosed())
})
}
private handleSocketClosed() {
for (const [, p] of this.pending) {
p.reject(new Error('WebSocket closed'))
}
this.pending.clear()
const notify = !this.voluntaryClose
const hadOpened = this.signalingEverOpened
this.voluntaryClose = false
this.signalingEverOpened = false
if (notify && hadOpened) {
try {
this.onWsClosed?.()
} finally {
this.onWsClosed = null
}
} else {
this.onWsClosed = null
}
}
close() {
this.voluntaryClose = true
try {
this.ws?.close()
} catch {
/* ignore */
}
}
subscribe(handler: Handler) {
this.handlers.push(handler)
return () => {
this.handlers = this.handlers.filter(h => h !== handler)
}
}
private emit(msg: { type: string; data?: unknown }) {
for (const h of this.handlers) h(msg)
}
private handleRaw(raw: unknown) {
let msg: { type: string; requestId?: string; data?: unknown; error?: string }
try {
msg = JSON.parse(String(raw))
} catch {
return
}
if (msg.type === 'response') {
const p = this.pending.get(msg.requestId || '')
if (p) {
this.pending.delete(msg.requestId || '')
if (msg.error) p.reject(new Error(msg.error))
else p.resolve(msg.data)
}
return
}
this.emit(msg as { type: string; data?: unknown })
}
request<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
if (this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('WebSocket is not open'))
}
const requestId = `r${++this.seq}`
return new Promise((resolve, reject) => {
this.pending.set(requestId, { resolve: resolve as (v: unknown) => void, reject })
try {
this.ws.send(JSON.stringify({ type, requestId, data }))
} catch (e) {
this.pending.delete(requestId)
reject(e instanceof Error ? e : new Error(String(e)))
}
})
}
async join(roomId: string, role: Role, displayName: string) {
return (await this.request('join', { roomId, role, displayName })) as {
peerId: string
roomId: string
role: Role
rtpCapabilities: T.RtpCapabilities
existingProducers: {
peerId: string
producerId: string
kind: T.MediaKind
appData: Record<string, unknown>
}[]
}
}
async loadDevice(rtpCapabilities: T.RtpCapabilities) {
this.device = new mediasoupClient.Device()
await this.device.load({ routerRtpCapabilities: rtpCapabilities })
return this.device
}
getDevice(): Device {
if (!this.device) throw new Error('Device not loaded')
return this.device
}
async createWebRtcTransport(direction: 'send' | 'recv') {
return (await this.request('createWebRtcTransport', { direction })) as {
id: string
iceParameters: T.IceParameters
iceCandidates: T.IceCandidate[]
dtlsParameters: T.DtlsParameters
}
}
async connectWebRtcTransport(transportId: string, dtlsParameters: T.DtlsParameters) {
await this.request('connectWebRtcTransport', { transportId, dtlsParameters })
}
async createSendTransport(): Promise<Transport> {
const device = this.getDevice()
const opt = await this.createWebRtcTransport('send')
const transport = device.createSendTransport(opt)
transport.on(
'connect',
(
{ dtlsParameters }: { dtlsParameters: T.DtlsParameters },
cb: () => void,
err: (e: Error) => void,
) => {
this.connectWebRtcTransport(transport.id, dtlsParameters)
.then(() => cb())
.catch(err)
},
)
transport.on(
'produce',
async (
{
kind,
rtpParameters,
appData,
}: {
kind: T.MediaKind
rtpParameters: T.RtpParameters
appData?: Record<string, unknown>
},
cb: (args: { id: string }) => void,
err: (e: Error) => void,
) => {
try {
const { id } = (await this.request('produce', {
transportId: transport.id,
kind,
rtpParameters,
appData: appData || {},
})) as { id: string }
cb({ id })
} catch (e) {
err(e instanceof Error ? e : new Error(String(e)))
}
},
)
return transport
}
async createRecvTransport(): Promise<Transport> {
const device = this.getDevice()
const opt = await this.createWebRtcTransport('recv')
const transport = device.createRecvTransport(opt)
transport.on(
'connect',
(
{ dtlsParameters }: { dtlsParameters: T.DtlsParameters },
cb: () => void,
err: (e: Error) => void,
) => {
this.connectWebRtcTransport(transport.id, dtlsParameters)
.then(() => cb())
.catch(err)
},
)
return transport
}
async consume(recvTransport: Transport, producerId: string): Promise<Consumer> {
const device = this.getDevice()
const { id, kind, rtpParameters, type } = (await this.request('consume', {
transportId: recvTransport.id,
producerId,
rtpCapabilities: device.rtpCapabilities,
})) as {
id: string
kind: T.MediaKind
rtpParameters: T.RtpParameters
type: T.ConsumerType
}
const consumer = await recvTransport.consume({ id, producerId, kind, rtpParameters, type })
await this.request('resumeConsumer', { consumerId: consumer.id })
return consumer
}
async pauseConsumer(consumerId: string) {
await this.request('pauseConsumer', { consumerId })
}
async resumeConsumer(consumerId: string) {
await this.request('resumeConsumer', { consumerId })
}
}

View File

@ -1,10 +0,0 @@
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw = {
name: 'live',
path: '/live',
component: () => import('./index.vue'),
meta: { title: '直播课堂(学员)', isPublic: true },
}
export default routes

View File

@ -9,7 +9,6 @@ import { isShufaAccount, SHUFA_PATH } from '@/page/app/app.ts'
import hud from '@/utils/hud'
import sass from '@/utils/sass'
const OSS_URL = import.meta.env.VITE_OSS_URL
const logo = sass.getLogo()
const qr = sass.getQrCode()
const url = window.location.href
@ -31,6 +30,15 @@ const hidePassword = ref(true)
// const loading = ref(false)
const onLogin = debounce(
async () => {
// native_api?.startReceiveTeacherScreen(
// JSON.stringify({
// host: '192.168.0.108',
// port: 30443,
// roomId: 'classroom',
// name: '',
// }),
// )
// return
if (!account.value) {
hud.error('请输入账号')
return
@ -148,7 +156,7 @@ defineOptions({
</div>
<div v-if="showIcp" class="icp-info">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">
粤ICP备2023060369号-9
蜀ICP备2026014928号-1
</a>
</div>
</div>

View File

@ -16,7 +16,6 @@ const lead_black_list = [
const path_white_list = [
'/',
'/login',
'/live',
'/subject', // 学科
'/subject/learningArtifact', // 学习神器
'/system/setting', // 设置