Skip to content

Commit 0834cab

Browse files
authored
Merge pull request #4 from ShouChenICU/dev
1、主页新增使用提示 2、新增监控端录制功能
2 parents 9368ad3 + 912fcd4 commit 0834cab

8 files changed

Lines changed: 260 additions & 16 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ WebCamera 是一个基于 WebRTC 技术的网络摄像头工具站,使用 Nuxt
1414
- [安装](#安装)
1515
- [使用](#使用)
1616
- [构建](#构建)
17+
- [Docker运行](#Docker运行)
1718
- [贡献](#贡献)
1819
- [许可证](#许可证)
1920

@@ -53,7 +54,7 @@ yarn run dev
5354

5455
2. 打开浏览器访问 `http://localhost:3000`
5556

56-
3. 摄像头先连接,然后监控页面填入和摄像头相同的连接ID,点连接,即可连接到摄像头
57+
3. 摄像头先连接,然后监控页面填入和摄像头相同的连接ID,点连接,即可连接到摄像头
5758

5859
## 构建
5960

@@ -70,15 +71,15 @@ yarn run build
7071
node server/index.mjs
7172
```
7273

73-
## Docker 运行
74+
**自部署请注意**: 浏览器媒体权限(摄像头和麦克风等)需要地址为`localhost`或使用`HTTPS`才能正常申请和启用,请自行配置`HTTPS`部署。
75+
76+
## Docker运行
7477

7578
```bash
7679
docker build -t webcamera .
7780
docker run -d -p 3000:3000 webcamera
7881
```
7982

80-
**自部署请注意**: 浏览器媒体权限(摄像头和麦克风等)需要地址为`localhost`或使用`HTTPS`才能正常申请和启用,请自行配置`HTTPS`部署。
81-
8283
## 贡献
8384

8485
我们欢迎任何形式的贡献!如果你有任何建议或发现了 bug,请提交一个 issue 或者发送一个 pull request。

components/NavBar.vue

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ onMounted(() => {
3232
ref="navElm"
3333
class="fixed top-0 right-0 left-0 flex flex-row items-center px-4 md:px-6 py-3 z-50"
3434
>
35-
<NuxtLink :to="locatePath('/')" class="contents"
36-
><img src="/favicon.webp" alt="web camera" class="size-6 mr-2" />WebCamera</NuxtLink
37-
>
35+
<NuxtLink :to="locatePath('/')" class="contents">
36+
<img src="/favicon.webp" alt="web camera" class="size-6 mr-2" />
37+
<div>
38+
<span>WebCamera</span>
39+
<span class="ml-2 text-xs">v0.1.1</span>
40+
</div>
41+
</NuxtLink>
3842
3943
<div class="flex-1"></div>
4044
@@ -47,6 +51,7 @@ onMounted(() => {
4751
size="xl"
4852
square
4953
to="https://github.com/ShouChenICU/WebCamera"
54+
target="_blank"
5055
>
5156
<template #leading>
5257
<Icon name="mdi:github" />

i18n.config.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ export default defineI18nConfig(() => ({
3434
webRec: 'Web recording',
3535
connectionID: 'Connection ID',
3636
audioDev: 'Audio device',
37-
videoDev: 'Video device'
37+
videoDev: 'Video device',
38+
record: 'Record',
39+
recordSettings: 'Record settings',
40+
format: 'Format',
41+
bps: 'Bitrate'
3842
},
3943
btn: {
4044
myFav: 'My Favorites',
@@ -71,7 +75,17 @@ export default defineI18nConfig(() => ({
7175
t3: 'Privacy & Security',
7276
d3: 'WebRTC incorporates built-in encryption to secure the communication content'
7377
},
74-
hint: {}
78+
usage: {
79+
usage: 'Usage',
80+
step1:
81+
'First, connect the camera. Go to the `Camera` page on the device to be used as a camera, enter the connection ID, and click connect.',
82+
step2:
83+
'On the monitoring side, go to the `Monitor` page, enter the same connection ID as the camera, and click connect to connect to the corresponding camera.'
84+
},
85+
hint: {
86+
recHint:
87+
'The current browser does not support the file access api, limiting the recording time to 10 minutes'
88+
}
7589
},
7690
zh: {
7791
welcome: '基于WebRTC的点对点网络摄像头实时监控工具',
@@ -104,7 +118,11 @@ export default defineI18nConfig(() => ({
104118
webRec: '网页录音',
105119
connectionID: '连接ID',
106120
audioDev: '音频设备',
107-
videoDev: '视频设备'
121+
videoDev: '视频设备',
122+
record: '录制',
123+
recordSettings: '录制设置',
124+
format: '格式',
125+
bps: '比特率'
108126
},
109127
btn: {
110128
myFav: '我的收藏',
@@ -141,7 +159,14 @@ export default defineI18nConfig(() => ({
141159
t3: '隐私安全',
142160
d3: 'WebRTC内置了加密技术,保护了通信内容的安全'
143161
},
144-
hint: {}
162+
usage: {
163+
usage: '使用方法',
164+
step1: '先连接摄像头,将作为摄像头的设备进入`摄像头`页面,输入连接ID,点连接。',
165+
step2: '监控端进入`监控`页面,填入与摄像头相同的连接ID,点连接,即可连到对应的摄像头'
166+
},
167+
hint: {
168+
recHint: '当前浏览器不支持文件访问API,限制录制时长10分钟'
169+
}
145170
}
146171
}
147172
}))

pages/camera.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,8 +420,9 @@ onUnmounted(() => {
420420
<UInput
421421
:type="isShowConnectId ? 'text' : 'password'"
422422
v-model="cameraId"
423-
:disabled="isConnecting"
423+
:disabled="isConnecting || logInfo.state === 'connected'"
424424
:ui="{ icon: { trailing: { pointer: '' } } }"
425+
size="lg"
425426
>
426427
<template #trailing>
427428
<UButton

pages/index.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ useSeoMeta({
2626
</script>
2727

2828
<template>
29-
<div>
29+
<div class="pb-12">
3030
<div
3131
class="relative pb-12 pt-24 inset-0 bg-white bg-[linear-gradient(to_right,#f0f0f0_1px,transparent_1px),linear-gradient(to_bottom,#f0f0f0_1px,transparent_1px)] bg-[size:6rem_4rem] dark:bg-[#121212] dark:bg-[linear-gradient(to_right,#303030_1px,transparent_1px),linear-gradient(to_bottom,#303030_1px,transparent_1px)]"
3232
>
@@ -62,5 +62,13 @@ useSeoMeta({
6262
</div>
6363
</div>
6464
</div>
65+
66+
<div class="md:px-12 p-4 space-y-2">
67+
<h2 class="text-lg flex flex-row items-center gap-1">
68+
<Icon name="fluent:data-usage-24-regular" />{{ $t('usage.usage') }}
69+
</h2>
70+
<p>1. {{ $t('usage.step1') }}</p>
71+
<p>2. {{ $t('usage.step2') }}</p>
72+
</div>
6573
</div>
6674
</template>

pages/monitor.vue

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,85 @@ const rtcNode = shallowRef<RTCNode>()
4444
let stateJobId: any
4545
let watchDogJonId: any
4646
47+
const isRecordSettingModalOpen = ref(false)
48+
const isRecording = ref(false)
49+
const recordingStartTime = ref(0)
50+
const recordingTimeStr = ref('')
51+
const recordSize = ref(0)
52+
const recordSetting = ref({
53+
mimeType: '',
54+
bitsPerSecond: 0
55+
})
56+
let videoChunks: Blob[] = []
57+
let fileHandler: FileSystemFileHandle
58+
let mediaRecoder: MediaRecorder
59+
4760
useSeoMeta({
4861
title: t('btn.monitor')
4962
})
5063
64+
/**
65+
* 开始录制
66+
*/
67+
async function startRecording() {
68+
if (isRecording.value || !curStream.value) {
69+
return
70+
}
71+
isRecording.value = true
72+
recordingStartTime.value = new Date().getTime()
73+
recordSize.value = 0
74+
videoChunks = []
75+
if (isModernFileAPIAvailable()) {
76+
// 支持现代化文件访问,数据直接写入磁盘
77+
fileHandler = await showSaveFilePicker({
78+
startIn: 'downloads',
79+
suggestedName: 'Video_' + formatDateTime(new Date(), 'yyyyMMddHHmmss') + '.webm'
80+
})
81+
const writer = await fileHandler.createWritable()
82+
83+
mediaRecoder = new MediaRecorder(curStream.value, { mimeType: recordSetting.value.mimeType })
84+
mediaRecoder.ondataavailable = (ev) => {
85+
recordingTimeStr.value = formatTime(new Date().getTime() - recordingStartTime.value)
86+
recordSize.value += ev.data.size
87+
return writer.write(ev.data)
88+
}
89+
mediaRecoder.onerror = console.error
90+
mediaRecoder.onstop = () => writer.close()
91+
mediaRecoder.start(200)
92+
} else {
93+
// 不支持现代化文件访问,录制数据保存在内存中(注意可能导致OOM
94+
95+
mediaRecoder = new MediaRecorder(curStream.value, { mimeType: recordSetting.value.mimeType })
96+
mediaRecoder.ondataavailable = (ev) => {
97+
const timeDuration = new Date().getTime() - recordingStartTime.value
98+
recordingTimeStr.value = formatTime(timeDuration)
99+
recordSize.value += ev.data.size
100+
videoChunks.push(ev.data)
101+
if (timeDuration >= 600e3) {
102+
// 超过十分钟,停止录制,避免OOM
103+
mediaRecoder.stop()
104+
}
105+
}
106+
mediaRecoder.onerror = console.error
107+
mediaRecoder.onstop = () => {
108+
const videoBlob = new Blob(videoChunks, { type: mediaRecoder.mimeType })
109+
doDownloadFromBlob(
110+
videoBlob,
111+
'Video_' + formatDateTime(new Date(), 'yyyyMMddHHmmss') + '.webm'
112+
)
113+
}
114+
mediaRecoder.start(200)
115+
}
116+
}
117+
118+
/**
119+
* 结束录制
120+
*/
121+
function stopRecording() {
122+
mediaRecoder?.stop()
123+
isRecording.value = false
124+
}
125+
51126
/**
52127
* 关闭媒体流
53128
*/
@@ -63,6 +138,7 @@ function closeStream() {
63138
* 断开连接并清理资源
64139
*/
65140
function disconnect() {
141+
stopRecording()
66142
clearInterval(stateJobId)
67143
clearInterval(watchDogJonId)
68144
logInfo.value.logs.push({
@@ -256,6 +332,10 @@ onMounted(() => {
256332
if (!cameraId.value) {
257333
cameraId.value = ''
258334
}
335+
const recordMimeTypes = getRecordMimeTypes()
336+
if (recordMimeTypes.length > 0) {
337+
recordSetting.value.mimeType = recordMimeTypes[0]
338+
}
259339
})
260340
261341
onUnmounted(() => {
@@ -271,8 +351,9 @@ onUnmounted(() => {
271351
<UInput
272352
:type="isShowConnectId ? 'text' : 'password'"
273353
v-model="cameraId"
274-
:disabled="isConnecting"
354+
:disabled="isConnecting || logInfo.state === 'connected'"
275355
:ui="{ icon: { trailing: { pointer: '' } } }"
356+
size="lg"
276357
>
277358
<template #trailing>
278359
<UButton
@@ -317,6 +398,40 @@ onUnmounted(() => {
317398
>{{ $t('btn.disconnect') }}</UButton
318399
>
319400
</div>
401+
402+
<UDivider class="my-8" :label="$t('label.record')" />
403+
404+
<div class="flex flex-row items-center">
405+
<span>
406+
{{ recordingStartTime === 0 ? '00:00:00' : recordingTimeStr }}
407+
</span>
408+
409+
<span class="ml-2">{{ humanFileSize(recordSize) }}</span>
410+
411+
<div class="flex-1"></div>
412+
413+
<UButton
414+
variant="ghost"
415+
color="gray"
416+
square
417+
class="mr-2"
418+
:disabled="isRecording"
419+
@click="() => (isRecordSettingModalOpen = true)"
420+
><template #leading><Icon name="solar:settings-linear" /></template
421+
></UButton>
422+
423+
<UButton color="green" @click="startRecording" v-if="!isRecording"
424+
><template #leading><icon name="solar:play-linear" /></template
425+
>{{ $t('btn.startRec') }}</UButton
426+
>
427+
<UButton color="rose" @click="stopRecording" v-else
428+
><template #leading><Icon name="solar:stop-linear" /></template
429+
>{{ $t('btn.stopRec') }}</UButton
430+
>
431+
</div>
432+
<div v-show="!isModernFileAPIAvailable()" class="text-xs">
433+
<span class="text-red-500">*</span>{{ $t('hint.recHint') }}
434+
</div>
320435
</div>
321436

322437
<div class="md:flex-1 md:p-4 mt-8 md:mt-0">
@@ -325,5 +440,23 @@ onUnmounted(() => {
325440
</div>
326441

327442
<LogBar :logInfo="logInfo" />
443+
444+
<UModal v-model="isRecordSettingModalOpen">
445+
<UCard :ui="{ ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
446+
<template #header>
447+
<div>{{ $t('label.recordSettings') }}</div>
448+
</template>
449+
450+
<div class="space-y-4">
451+
<UFormGroup :label="$t('label.format')">
452+
<USelectMenu :options="getRecordMimeTypes()" v-model="recordSetting.mimeType" />
453+
</UFormGroup>
454+
455+
<!-- <UFormGroup :label="$t('label.bps')">
456+
<UInput type="number" v-model="recordSetting.bitsPerSecond" />
457+
</UFormGroup> -->
458+
</div>
459+
</UCard>
460+
</UModal>
328461
</div>
329462
</template>

utils/DateUtils.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,26 @@ export function formatDateTime(date, format) {
3030
return format
3131
}
3232

33+
/**
34+
* 将毫秒数格式化为 HH:mm:ss
35+
* @param {number} milliseconds
36+
* @returns
37+
*/
38+
export function formatTime(milliseconds) {
39+
// 计算小时数
40+
const hours = Math.floor(milliseconds / (1000 * 60 * 60))
41+
// 计算剩余的分钟数
42+
const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60))
43+
// 计算剩余的秒数
44+
const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000)
45+
46+
// 格式化分钟和秒数为两位数
47+
const formattedMinutes = minutes.toString().padStart(2, '0')
48+
const formattedSeconds = seconds.toString().padStart(2, '0')
49+
50+
return `${hours}:${formattedMinutes}:${formattedSeconds}`
51+
}
52+
3353
export function currentTime(format) {
3454
if (format === undefined) {
3555
format = 'yyyy/MM/dd HH:mm:ss'

0 commit comments

Comments
 (0)