feat:增加直播模块
This commit is contained in:
parent
7ac31e52a4
commit
d1f364e230
@ -43,6 +43,7 @@
|
||||
"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",
|
||||
|
||||
106
pnpm-lock.yaml
generated
106
pnpm-lock.yaml
generated
@ -92,6 +92,9 @@ 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)
|
||||
@ -655,6 +658,14 @@ 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==}
|
||||
|
||||
@ -909,9 +920,15 @@ packages:
|
||||
'@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==}
|
||||
|
||||
@ -1341,6 +1358,10 @@ 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==}
|
||||
|
||||
@ -1758,6 +1779,15 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -2078,6 +2108,10 @@ 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'}
|
||||
@ -2088,6 +2122,10 @@ 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==}
|
||||
|
||||
@ -2256,6 +2294,10 @@ 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'}
|
||||
@ -2712,6 +2754,10 @@ 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==}
|
||||
|
||||
@ -3220,6 +3266,10 @@ 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
|
||||
@ -3363,6 +3413,10 @@ 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'}
|
||||
@ -4192,6 +4246,12 @@ 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':
|
||||
@ -4364,8 +4424,14 @@ snapshots:
|
||||
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': {}
|
||||
@ -4914,6 +4980,12 @@ 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
|
||||
@ -5360,6 +5432,12 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
optionalDependencies:
|
||||
supports-color: 10.2.2
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decode-named-character-reference@1.1.0:
|
||||
@ -5789,6 +5867,8 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@8.0.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@ -5807,6 +5887,10 @@ 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:
|
||||
@ -5993,6 +6077,12 @@ 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: {}
|
||||
@ -6441,6 +6531,18 @@ 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: {}
|
||||
@ -7040,6 +7142,8 @@ snapshots:
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
sdp-transform@3.0.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.2: {}
|
||||
@ -7197,6 +7301,8 @@ snapshots:
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
@ -46,8 +46,6 @@ const { system, checkUpdate } = globalStore()
|
||||
const isHomePageLoading = ref(false)
|
||||
|
||||
const navMain = computed(() => {
|
||||
// const { purchasedInspector, nextInspectorCourseDate, conductInspectorPlanId } =
|
||||
// inspectorInfo.value ?? {}
|
||||
return [
|
||||
{
|
||||
id: '2',
|
||||
@ -55,23 +53,6 @@ const navMain = computed(() => {
|
||||
key: 'subject',
|
||||
path: '/subject',
|
||||
},
|
||||
// {
|
||||
// id: '3',
|
||||
// url: homeUrge,
|
||||
// key: 'urge',
|
||||
// path: '',
|
||||
// show: !isHide.value,
|
||||
// // id: '3',
|
||||
// // url: homeUrge,
|
||||
// // key: 'urge',
|
||||
// // path: conductInspectorPlanId
|
||||
// // ? '/urge/learning'
|
||||
// // : purchasedInspector
|
||||
// // ? '/urge/timetable'
|
||||
// // : '/urge/service',
|
||||
// // desc: nextInspectorCourseDate && dayjs(nextInspectorCourseDate).format('MM-DD HH:mm'),
|
||||
// // showTip: true,
|
||||
// },
|
||||
{
|
||||
id: '5',
|
||||
url: homeTask,
|
||||
@ -95,6 +76,9 @@ const navMain = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const navMainVisible = computed(() => navMain.value.filter(item => !(item.show === false)))
|
||||
const navMainIsFourGrid = computed(() => navMainVisible.value.length === 4)
|
||||
|
||||
const navLeft = computed(() => {
|
||||
const hideAppSchools = ['勤智', '德艺', '领航', '黄古']
|
||||
// 如果是特定学校的
|
||||
@ -467,9 +451,9 @@ onUnmounted(() => {
|
||||
@click="clickPhoto"
|
||||
></div>
|
||||
</div> -->
|
||||
<div class="right">
|
||||
<template v-for="item in navMain" :key="item.id">
|
||||
<div v-if="!(item.show === false)" @click="handleNav(item)">
|
||||
<div class="right" :class="{ 'right--four': navMainIsFourGrid }">
|
||||
<template v-for="item in navMainVisible" :key="item.id">
|
||||
<div @click="handleNav(item)">
|
||||
<img v-if="item" :src="item.url" />
|
||||
<Icon v-if="item.showTip" class="warning" name="warning" size="32" />
|
||||
<!-- <div v-if="item.desc" class="desc">{{ item.desc }}</div> -->
|
||||
@ -712,6 +696,28 @@ onUnmounted(() => {
|
||||
font-family: $font-cn;
|
||||
}
|
||||
}
|
||||
|
||||
&--four {
|
||||
$width: 30vh;
|
||||
$rowGap: 5vh;
|
||||
$columnGap: 20vh;
|
||||
margin-top: -9vh;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, $width);
|
||||
grid-auto-rows: $width;
|
||||
row-gap: $rowGap;
|
||||
column-gap: $columnGap;
|
||||
|
||||
div {
|
||||
width: $width;
|
||||
height: $width;
|
||||
|
||||
img {
|
||||
width: $width;
|
||||
height: $width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
src/page/live/StreamVideo.vue
Normal file
36
src/page/live/StreamVideo.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<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>
|
||||
322
src/page/live/index.vue
Normal file
322
src/page/live/index.vue
Normal file
@ -0,0 +1,322 @@
|
||||
<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>
|
||||
276
src/page/live/room-ws-client.ts
Normal file
276
src/page/live/room-ws-client.ts
Normal file
@ -0,0 +1,276 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
10
src/page/live/routes.ts
Normal file
10
src/page/live/routes.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw = {
|
||||
name: 'live',
|
||||
path: '/live',
|
||||
component: () => import('./index.vue'),
|
||||
meta: { title: '直播课堂(学员)', isPublic: true },
|
||||
}
|
||||
|
||||
export default routes
|
||||
@ -16,6 +16,7 @@ const lead_black_list = [
|
||||
const path_white_list = [
|
||||
'/',
|
||||
'/login',
|
||||
'/live',
|
||||
'/subject', // 学科
|
||||
'/subject/learningArtifact', // 学习神器
|
||||
'/system/setting', // 设置
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user