新增摄像头

This commit is contained in:
kura 2026-05-07 22:42:54 +08:00
parent 7d39a6dbe9
commit 6b5e402f3a
5 changed files with 627 additions and 47 deletions

View File

@ -46,6 +46,22 @@
>
<img src="/static/phone.png" alt="通话" />
</div>
<div
class="connect-item"
:class="{ disabled: !isConnected, active: isCameraActive }"
title="摄像头"
@click="requestCamera"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</div>
</div>
<input
v-model="targetId"
@ -126,6 +142,7 @@ const isInboundConnected = ref(false);
const isConnected = ref(false);
const isDesktopActive = ref(false);
const isCallActive = ref(false);
const isCameraActive = ref(false);
//
const selectedLocalFiles = ref<string[]>([]);
const selectedRemoteFiles = ref<string[]>([]);
@ -150,6 +167,15 @@ const requestCall = () => {
}
peer.requestCall(targetId.value);
};
const requestCamera = () => {
if (!isConnected.value) return;
if (isCameraActive.value) {
peer.endMedia(sign2peerid(targetId.value), "camera");
return;
}
peer.requestCamera(targetId.value);
};
const shareUrl = async () => {
if (myId.value) {
const url =
@ -289,6 +315,7 @@ onMounted(() => {
clearConnectedPeer(event.detail.peer);
isDesktopActive.value = false;
isCallActive.value = false;
isCameraActive.value = false;
notification.error({
message: event.detail.peer + "连接已断开",
});
@ -307,6 +334,8 @@ onMounted(() => {
isDesktopActive.value = true;
} else if (event.detail.type === "call") {
isCallActive.value = true;
} else if (event.detail.type === "camera") {
isCameraActive.value = true;
}
}) as EventListener);
@ -322,15 +351,24 @@ onMounted(() => {
}
}) as EventListener);
peer.on("camera-started", ((event: CustomEvent) => {
if (isActivePeer(event.detail.peerId)) {
isCameraActive.value = true;
}
}) as EventListener);
peer.on("media-ended", ((event: CustomEvent) => {
if (!isActivePeer(event.detail.peerId)) return;
if (event.detail.type === "desktop") {
isDesktopActive.value = false;
} else if (event.detail.type === "call") {
isCallActive.value = false;
} else if (event.detail.type === "camera") {
isCameraActive.value = false;
}
}) as EventListener);
});
</script>
<style scoped>
@ -418,6 +456,13 @@ onMounted(() => {
height: 100%;
}
.connect-item svg {
width: 22px;
height: 22px;
margin: auto;
color: #1677ff;
}
.connect-actions,
.connect-section {
display: flex;

View File

@ -130,7 +130,7 @@
<div
v-if="isRemoteDesktopActive && isDesktopCollapsed"
class="desktop-collapsed-tab"
:class="{ 'is-stacked': isCallActive && !isCallCollapsed }"
:class="desktopCollapsedClasses"
@click="toggleDesktopCollapse"
title="展开桌面"
aria-label="展开桌面"
@ -216,7 +216,7 @@
<button
v-if="isDesktopLocalSharing && isDesktopShareCollapsed"
class="desktop-sharing-tab"
:class="{ 'is-stacked': isCallActive && !isCallCollapsed }"
:class="desktopCollapsedClasses"
@click="toggleDesktopShareCollapse"
title="展开桌面预览提示"
aria-label="展开桌面预览提示"
@ -237,17 +237,215 @@
</button>
</Transition>
<!-- Remote Camera Floating Panel -->
<Transition name="panel-slide">
<div
v-if="isRemoteCameraActive && !isCameraCollapsed"
class="camera-floating-panel"
:class="{
'above-call': isCallActive && !isCallCollapsed,
'above-sharing':
!isCallActive && isDesktopLocalSharing && !isDesktopShareCollapsed,
}"
>
<div class="call-panel-header">
<div class="panel-header-left">
<span class="panel-header-icon camera">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</span>
<span>远程摄像头</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleCameraCollapse"
title="收起摄像头"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button class="panel-icon-btn" @click="endCamera" title="结束摄像头">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<div class="camera-panel-body">
<video
v-if="cameraStream"
ref="cameraVideoRef"
autoplay
playsinline
></video>
<div v-else class="loading-state">
<span>等待摄像头画面...</span>
</div>
</div>
</div>
</Transition>
<Transition name="tab-slide">
<button
v-if="isRemoteCameraActive && isCameraCollapsed"
class="camera-collapsed-tab"
:class="cameraCollapsedClasses"
@click="toggleCameraCollapse"
title="展开摄像头"
aria-label="展开摄像头"
>
<span class="sharing-status-dot"></span>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</button>
</Transition>
<!-- Local Camera Sharing Indicator -->
<Transition name="panel-slide">
<div
v-if="isCameraLocalSharing && !isCameraShareCollapsed"
class="desktop-sharing-panel camera-sharing-panel"
>
<div class="sharing-panel-header">
<div class="sharing-panel-title">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
<span>摄像头正在被预览</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleCameraShareCollapse"
title="收起提示"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
</button>
<button class="panel-icon-btn" @click="endCamera" title="结束摄像头">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<div class="sharing-panel-body">
<div class="sharing-status-dot"></div>
<span>对方正在查看你的摄像头</span>
</div>
</div>
</Transition>
<Transition name="tab-slide">
<button
v-if="isCameraLocalSharing && isCameraShareCollapsed"
class="camera-sharing-tab"
:class="cameraCollapsedClasses"
@click="toggleCameraShareCollapse"
title="展开摄像头预览提示"
aria-label="展开摄像头预览提示"
>
<span class="sharing-status-dot"></span>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
</button>
</Transition>
<!-- Incoming Call Request -->
<Transition name="panel-slide">
<div
v-if="incomingCallRequest"
class="call-floating-panel incoming-call-panel"
:class="{ 'is-stacked': isDesktopLocalSharing }"
:class="{ 'is-stacked': isDesktopLocalSharing || isCameraLocalSharing }"
>
<div class="call-panel-header">
<div class="panel-header-left">
<span class="panel-header-icon">
<span
class="panel-header-icon"
:class="{ camera: incomingCallRequest.type === 'camera' }"
>
<svg
v-if="incomingCallRequest.type === 'camera'"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
<svg
v-else
width="18"
height="18"
viewBox="0 0 24 24"
@ -260,12 +458,32 @@
/>
</svg>
</span>
<span>语音通话请求</span>
<span>{{
incomingCallRequest.type === "camera"
? "摄像头请求"
: "语音通话请求"
}}</span>
</div>
</div>
<div class="call-panel-body">
<div class="call-status-icon">
<div
class="call-status-icon"
:class="{ camera: incomingCallRequest.type === 'camera' }"
>
<svg
v-if="incomingCallRequest.type === 'camera'"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M23 7l-7 5 7 5V7z" />
<rect x="1" y="5" width="15" height="14" rx="2" />
</svg>
<svg
v-else
width="48"
height="48"
viewBox="0 0 24 24"
@ -278,7 +496,13 @@
/>
</svg>
</div>
<div class="call-status-text">对方请求语音通话</div>
<div class="call-status-text">
{{
incomingCallRequest.type === "camera"
? "对方请求查看摄像头"
: "对方请求语音通话"
}}
</div>
</div>
<div class="incoming-call-actions">
<button
@ -304,7 +528,7 @@
<div
v-if="isCallActive && !isCallCollapsed && !incomingCallRequest"
class="call-floating-panel"
:class="{ 'is-stacked': isDesktopLocalSharing }"
:class="{ 'is-stacked': isDesktopLocalSharing || isCameraLocalSharing }"
>
<div class="call-panel-header">
<div class="panel-header-left">
@ -434,7 +658,11 @@
<div
v-if="isCallCollapsed && isCallActive"
class="call-collapsed-btn"
:class="{ 'is-stacked': isDesktopLocalSharing && !isDesktopShareCollapsed }"
:class="{
'is-stacked':
(isDesktopLocalSharing && !isDesktopShareCollapsed) ||
(isCameraLocalSharing && !isCameraShareCollapsed),
}"
@click="toggleCallCollapse"
title="展开通话"
>
@ -468,14 +696,14 @@ const props = defineProps<{
interface StreamEvent extends CustomEvent {
detail: {
stream: MediaStream;
type: "desktop" | "call";
type: "desktop" | "call" | "camera";
peerId: string;
};
}
interface MediaEndedEvent extends CustomEvent {
detail: {
type: "desktop" | "call";
type: "desktop" | "call" | "camera";
peerId: string;
};
}
@ -490,7 +718,7 @@ interface MediaStartedEvent extends CustomEvent {
interface MediaRequestEvent extends CustomEvent {
detail: {
peerId: string;
type: "desktop" | "call";
type: "desktop" | "call" | "camera";
accept: () => Promise<void> | void;
reject: () => void;
};
@ -498,24 +726,31 @@ interface MediaRequestEvent extends CustomEvent {
interface IncomingCallRequest {
peerId: string;
type: "call" | "camera";
accept: () => Promise<void> | void;
reject: () => void;
}
const videoRef = ref<HTMLVideoElement | null>(null);
const cameraVideoRef = ref<HTMLVideoElement | null>(null);
const audioRef = ref<HTMLAudioElement | null>(null);
const videoContainer = ref<HTMLElement | null>(null);
const activeMediaPeerId = ref("");
const incomingCallRequest = ref<IncomingCallRequest | null>(null);
const desktopStream = ref<MediaStream | null>(null);
const cameraStream = ref<MediaStream | null>(null);
const callStream = ref<MediaStream | null>(null);
const isDesktopActive = ref(false);
const isDesktopLocalSharing = ref(false);
const isCameraActive = ref(false);
const isCameraLocalSharing = ref(false);
const isCallActive = ref(false);
const isFullscreen = ref(false);
const isCallMuted = ref(false);
const isDesktopCollapsed = ref(false);
const isDesktopShareCollapsed = ref(false);
const isCameraCollapsed = ref(false);
const isCameraShareCollapsed = ref(false);
const isCallCollapsed = ref(false);
const isIncomingCallHandling = ref(false);
const callDuration = ref(0);
@ -524,6 +759,30 @@ let durationTimer: number | null = null;
const isRemoteDesktopActive = computed(
() => isDesktopActive.value && !isDesktopLocalSharing.value,
);
const isRemoteCameraActive = computed(
() => isCameraActive.value && !isCameraLocalSharing.value,
);
const isCollapsedCallVisible = computed(
() => isCallActive.value && isCallCollapsed.value,
);
const isCollapsedCameraVisible = computed(
() =>
(isRemoteCameraActive.value && isCameraCollapsed.value) ||
(isCameraLocalSharing.value && isCameraShareCollapsed.value),
);
const isExpandedCallPanelVisible = computed(
() =>
isCallActive.value && !isCallCollapsed.value && !incomingCallRequest.value,
);
const cameraCollapsedClasses = computed(() => ({
"slot-2": isCollapsedCallVisible.value,
"above-call-panel": isExpandedCallPanelVisible.value,
}));
const desktopCollapsedClasses = computed(() => ({
"slot-3": isCollapsedCallVisible.value && isCollapsedCameraVisible.value,
"slot-2": isCollapsedCallVisible.value !== isCollapsedCameraVisible.value,
"above-call-panel": isExpandedCallPanelVisible.value,
}));
const callDurationStr = computed(() => {
const mins = Math.floor(callDuration.value / 60);
@ -565,6 +824,8 @@ const resetPeerWhenIdle = () => {
if (
!isDesktopActive.value &&
!isDesktopLocalSharing.value &&
!isCameraActive.value &&
!isCameraLocalSharing.value &&
!isCallActive.value
) {
activeMediaPeerId.value = "";
@ -583,6 +844,18 @@ const bindDesktopVideo = async () => {
});
};
const bindCameraVideo = async () => {
await nextTick();
if (!cameraVideoRef.value || !cameraStream.value) return;
if (cameraVideoRef.value.srcObject !== cameraStream.value) {
cameraVideoRef.value.srcObject = cameraStream.value;
}
cameraVideoRef.value.muted = true;
cameraVideoRef.value.play().catch(() => {
message.warning("浏览器阻止了自动播放,请点击摄像头画面恢复播放");
});
};
const toggleDesktopCollapse = async () => {
isDesktopCollapsed.value = !isDesktopCollapsed.value;
if (!isDesktopCollapsed.value) {
@ -590,19 +863,31 @@ const toggleDesktopCollapse = async () => {
}
};
const toggleCameraCollapse = async () => {
isCameraCollapsed.value = !isCameraCollapsed.value;
if (!isCameraCollapsed.value) {
await bindCameraVideo();
}
};
const toggleDesktopShareCollapse = () => {
isDesktopShareCollapsed.value = !isDesktopShareCollapsed.value;
};
const toggleCameraShareCollapse = () => {
isCameraShareCollapsed.value = !isCameraShareCollapsed.value;
};
const toggleCallCollapse = () => {
isCallCollapsed.value = !isCallCollapsed.value;
};
const handleMediaRequest = (event: MediaRequestEvent) => {
const { peerId, type, accept, reject } = event.detail;
if (type !== "call" || !isCurrentPeer(peerId)) return;
if ((type !== "call" && type !== "camera") || !isCurrentPeer(peerId)) return;
incomingCallRequest.value = {
peerId,
type,
accept,
reject,
};
@ -636,6 +921,12 @@ const handleStream = async (event: StreamEvent) => {
desktopStream.value = remoteStream;
isDesktopCollapsed.value = false;
await bindDesktopVideo();
} else if (type === "camera") {
isCameraActive.value = true;
isCameraLocalSharing.value = false;
cameraStream.value = remoteStream;
isCameraCollapsed.value = false;
await bindCameraVideo();
} else if (type === "call") {
isCallActive.value = true;
callStream.value = remoteStream;
@ -660,6 +951,19 @@ const handleDesktopStarted = (event: MediaStartedEvent) => {
isDesktopCollapsed.value = false;
};
const handleCameraStarted = (event: MediaStartedEvent) => {
if (!isCurrentPeer(event.detail.peerId)) return;
if (event.detail.role === "sharer") {
isCameraActive.value = true;
isCameraLocalSharing.value = true;
isCameraShareCollapsed.value = false;
return;
}
isCameraActive.value = true;
isCameraLocalSharing.value = false;
isCameraCollapsed.value = false;
};
const handleCallStarted = (event: MediaStartedEvent) => {
if (!isCurrentPeer(event.detail.peerId)) return;
isCallActive.value = true;
@ -684,6 +988,16 @@ const handleMediaEnded = (event: MediaEndedEvent) => {
if (!isCallActive.value) {
stopDurationTimer();
}
} else if (type === "camera") {
isCameraActive.value = false;
isCameraLocalSharing.value = false;
cameraStream.value = null;
isCameraCollapsed.value = false;
isCameraShareCollapsed.value = false;
if (cameraVideoRef.value) {
cameraVideoRef.value.srcObject = null;
}
message.info("摄像头预览已结束");
} else if (type === "call") {
isCallActive.value = false;
callStream.value = null;
@ -707,6 +1021,10 @@ const endCall = () => {
stopDurationTimer();
};
const endCamera = () => {
peer.endMedia(getMediaPeerId(), "camera");
};
const toggleCallMuted = () => {
isCallMuted.value = !isCallMuted.value;
if (audioRef.value) {
@ -747,6 +1065,9 @@ const desktopStartedListener: EventListener = (event) => {
const callStartedListener: EventListener = (event) => {
handleCallStarted(event as MediaStartedEvent);
};
const cameraStartedListener: EventListener = (event) => {
handleCameraStarted(event as MediaStartedEvent);
};
const mediaRequestListener: EventListener = (event) => {
handleMediaRequest(event as MediaRequestEvent);
};
@ -755,6 +1076,7 @@ onMounted(() => {
peer.on("media-request", mediaRequestListener);
peer.on("stream", streamListener);
peer.on("desktop-started", desktopStartedListener);
peer.on("camera-started", cameraStartedListener);
peer.on("call-started", callStartedListener);
peer.on("media-ended", mediaEndedListener);
document.addEventListener("fullscreenchange", handleFullscreenChange);
@ -764,11 +1086,13 @@ onUnmounted(() => {
peer.off("media-request", mediaRequestListener);
peer.off("stream", streamListener);
peer.off("desktop-started", desktopStartedListener);
peer.off("camera-started", cameraStartedListener);
peer.off("call-started", callStartedListener);
peer.off("media-ended", mediaEndedListener);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
stopDurationTimer();
desktopStream.value?.getTracks().forEach((track) => track.stop());
cameraStream.value?.getTracks().forEach((track) => track.stop());
callStream.value?.getTracks().forEach((track) => track.stop());
});
</script>
@ -992,7 +1316,7 @@ video {
.desktop-collapsed-tab {
position: fixed;
right: 16px;
bottom: 84px;
bottom: 24px;
z-index: 1002;
display: flex;
align-items: center;
@ -1014,10 +1338,6 @@ video {
transform: scale(1.08);
}
.desktop-collapsed-tab.is-stacked {
bottom: 372px;
}
.desktop-collapsed-tab .sharing-status-dot {
position: absolute;
right: 7px;
@ -1084,7 +1404,7 @@ video {
.desktop-sharing-tab {
position: fixed;
right: 16px;
bottom: 84px;
bottom: 24px;
z-index: 1002;
display: flex;
align-items: center;
@ -1106,10 +1426,6 @@ video {
transform: scale(1.08);
}
.desktop-sharing-tab.is-stacked {
bottom: 372px;
}
.desktop-sharing-tab .sharing-status-dot {
position: absolute;
right: 7px;
@ -1118,6 +1434,103 @@ video {
height: 7px;
}
.camera-sharing-panel {
bottom: 148px;
}
.camera-sharing-tab,
.camera-collapsed-tab {
position: fixed;
right: 16px;
bottom: 24px;
z-index: 1002;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: none;
border-radius: 50%;
background: linear-gradient(145deg, #1b2332, #121821);
color: #40a9ff;
cursor: pointer;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.08);
transition: all 0.25s;
}
.camera-sharing-tab:hover,
.camera-collapsed-tab:hover {
transform: scale(1.08);
}
.camera-sharing-tab.slot-2,
.camera-collapsed-tab.slot-2,
.desktop-collapsed-tab.slot-2,
.desktop-sharing-tab.slot-2 {
bottom: 84px;
}
.desktop-collapsed-tab.slot-3,
.desktop-sharing-tab.slot-3 {
bottom: 144px;
}
.camera-sharing-tab.above-call-panel,
.camera-collapsed-tab.above-call-panel {
bottom: 300px;
}
.desktop-collapsed-tab.above-call-panel,
.desktop-sharing-tab.above-call-panel {
bottom: 372px;
}
.camera-sharing-tab .sharing-status-dot,
.camera-collapsed-tab .sharing-status-dot {
position: absolute;
right: 7px;
top: 7px;
width: 7px;
height: 7px;
}
.camera-floating-panel {
position: fixed;
right: 24px;
bottom: 24px;
width: min(420px, calc(100vw - 48px));
z-index: 1002;
overflow: hidden;
color: #fff;
background: linear-gradient(145deg, #111827, #162033);
border-radius: 16px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.06);
}
.camera-floating-panel.above-call {
bottom: 360px;
}
.camera-floating-panel.above-sharing {
bottom: 148px;
}
.camera-panel-body {
height: 260px;
background: #050505;
}
.camera-panel-body video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
/* Call-Only Floating Panel (expanded) */
.call-floating-panel {
position: fixed;
@ -1205,6 +1618,10 @@ video {
color: #52c41a;
}
.panel-header-icon.camera {
color: #40a9ff;
}
.panel-header-actions {
display: flex;
align-items: center;
@ -1253,6 +1670,11 @@ video {
animation: pulse 2s ease-in-out infinite;
}
.call-status-icon.camera {
background: rgba(64, 169, 255, 0.1);
color: #40a9ff;
}
@keyframes pulse {
0%,
100% {

View File

@ -9,7 +9,7 @@
<div class="permission-container">
<div
class="permission-item"
v-for="(allowed, permission) in localPermissionSet"
v-for="permission in permissionsToShow"
:key="permission"
>
<Checkbox v-model:checked="localPermissionSet[permission]">
@ -23,12 +23,18 @@
<script setup lang="ts">
import { Modal, Checkbox } from "ant-design-vue";
import { ref, reactive } from "vue";
import { computed, onMounted, ref, reactive } from "vue";
import { Permission } from "../utils/peer";
import { getPermissionSet, setPermissionSet } from "../utils/common";
const visible = ref(false);
const localPermissionSet = reactive({ ...getPermissionSet() });
const hasCamera = ref(false);
const permissionsToShow = computed(() =>
Object.values(Permission).filter(
(permission) => permission !== Permission.camera || hasCamera.value,
),
);
const permissionLabels: Record<Permission, string> = {
[Permission.edit]: "编辑权限",
@ -36,6 +42,7 @@ const permissionLabels: Record<Permission, string> = {
[Permission.download]: "下载权限",
[Permission.desktop]: "桌面预览权限",
[Permission.call]: "语音通话权限",
[Permission.camera]: "摄像头权限",
};
const permissionDescs: Record<Permission, string> = {
@ -44,6 +51,16 @@ const permissionDescs: Record<Permission, string> = {
[Permission.download]: "允许传输文件到本地",
[Permission.desktop]: "允许对方请求预览当前桌面",
[Permission.call]: "允许对方请求语音通话",
[Permission.camera]: "允许对方请求查看当前摄像头",
};
const checkCamera = async () => {
try {
const devices = await navigator.mediaDevices?.enumerateDevices?.();
hasCamera.value = devices?.some((device) => device.kind === "videoinput") ?? false;
} catch {
hasCamera.value = false;
}
};
const handleOk = () => {
@ -57,9 +74,14 @@ const handleCancel = () => {
};
const showDialog = () => {
checkCamera();
visible.value = true;
};
onMounted(() => {
checkCamera();
});
defineExpose({
showDialog,
});

View File

@ -64,12 +64,14 @@ export const getUrlParam = (key: string) => {
let permissionSet: { [key in Permission]: boolean } = null;
export function getPermissionSet(): { [key in Permission]: boolean } {
if (!permissionSet) {
permissionSet = getCache('permissionSet') || {
permissionSet = {
edit: false,
view: true,
download: true,
desktop: true,
call: false
call: false,
camera: false,
...(getCache('permissionSet') || {})
};
}
return permissionSet;

View File

@ -15,7 +15,7 @@ import {
confirmWin,
} from "./common";
type MediaType = "desktop" | "call";
type MediaType = "desktop" | "call" | "camera";
// 发送超时时间(毫秒)
const SEND_TIMEOUT = 30000;
@ -78,11 +78,24 @@ class Peer extends EventTarget {
// 处理媒体连接
this.peer.on("call", async (call) => {
//弹出确认框
const type = call.metadata?.type === "desktop" ? "desktop" : "call";
const title = type === "desktop" ? "屏幕共享请求" : "语音通话请求";
const content = `${call.peer} 请求与您进行${type === "desktop" ? "屏幕共享" : "语音通话"},是否接受?`;
const mediaType = call.metadata?.type;
const type: MediaType =
mediaType === "desktop" || mediaType === "camera" ? mediaType : "call";
const title =
type === "desktop"
? "屏幕共享请求"
: type === "camera"
? "摄像头请求"
: "语音通话请求";
const mediaName =
type === "desktop" ? "屏幕共享" : type === "camera" ? "摄像头" : "语音通话";
const content = `${call.peer} 请求与您进行${mediaName},是否接受?`;
const permission =
type === "desktop" ? Permission.desktop : Permission.call;
type === "desktop"
? Permission.desktop
: type === "camera"
? Permission.camera
: Permission.call;
// 保存 this 引用
const self = this;
@ -99,6 +112,11 @@ class Peer extends EventTarget {
video: true,
audio: false,
});
} else if (type === "camera") {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
});
} else {
stream = await navigator.mediaDevices.getUserMedia({
audio: true,
@ -107,15 +125,29 @@ class Peer extends EventTarget {
}
call.answer(stream);
self.setupMediaConnection(call, type, stream, type !== "desktop");
self.setupMediaConnection(
call,
type,
stream,
type !== "desktop" && type !== "camera",
);
self.dispatchEvent(
new CustomEvent(
type === "desktop" ? "desktop-started" : "call-started",
type === "desktop"
? "desktop-started"
: type === "camera"
? "camera-started"
: "call-started",
{
detail: {
peerId: call.peer,
role: type === "desktop" ? "sharer" : "caller",
role:
type === "desktop"
? "sharer"
: type === "camera"
? "sharer"
: "caller",
},
},
),
@ -124,23 +156,34 @@ class Peer extends EventTarget {
console.error("获取媒体设备失败:", error);
self.dispatchEvent(
new CustomEvent("error", {
detail: "无法访问" + (type === "desktop" ? "屏幕" : "麦克风"),
detail:
"无法访问" +
(type === "desktop"
? "屏幕"
: type === "camera"
? "摄像头"
: "麦克风"),
}),
);
message.error("无法访问" + (type === "desktop" ? "屏幕" : "麦克风"));
message.error(
"无法访问" +
(type === "desktop"
? "屏幕"
: type === "camera"
? "摄像头"
: "麦克风"),
);
}
};
const onReject = () => {
if (handled) return;
handled = true;
call.close();
message.info(
"已拒绝" + (type === "desktop" ? "屏幕共享" : "语音通话") + "请求",
);
message.info("已拒绝" + mediaName + "请求");
};
if (getPermissionSet()[permission]) {
await onOk();
} else if (type === "call") {
} else if (type === "call" || type === "camera") {
this.dispatchEvent(
new CustomEvent("media-request", {
detail: {
@ -292,10 +335,43 @@ class Peer extends EventTarget {
}
}
async requestCamera(id: string) {
if (!this.remoteConnection) {
notification.error({
message: "请创建连接",
});
return;
}
try {
const offerStream = this.createDesktopOfferStream();
const call = this.peer.call(sign2peerid(id), offerStream, {
metadata: { type: "camera" },
});
this.setupMediaConnection(call, "camera", offerStream);
this.dispatchEvent(
new CustomEvent("camera-started", {
detail: {
peerId: call.peer,
role: "viewer",
},
}),
);
} catch (error) {
console.error("创建摄像头连接失败:", error);
notification.error({
message: "创建摄像头连接失败",
});
}
}
// 结束媒体连接
endMedia(peerId?: string, type?: MediaType) {
if (peerId) {
const mediaTypes: MediaType[] = type ? [type] : ["desktop", "call"];
const mediaTypes: MediaType[] = type ? [type] : ["desktop", "call", "camera"];
mediaTypes.forEach((mediaType) => {
this.endMediaConnection(peerId, mediaType);
if (this.remoteConnection) {
@ -304,6 +380,8 @@ class Peer extends EventTarget {
type:
mediaType === "desktop"
? MessageType.end_desktop
: mediaType === "camera"
? MessageType.end_camera
: MessageType.end_call,
data: {
peerId,
@ -480,7 +558,10 @@ class Peer extends EventTarget {
const hasVideo = remoteStream.getVideoTracks().length > 0;
const hasAudio = remoteStream.getAudioTracks().length > 0;
if ((type === "desktop" && hasVideo) || (type === "call" && hasAudio)) {
if (
((type === "desktop" || type === "camera") && hasVideo) ||
(type === "call" && hasAudio)
) {
this.updateMediaConnection(peerId, type, {
connection: call,
remoteStream,
@ -714,11 +795,16 @@ class Peer extends EventTarget {
}
resData.data = "ok";
break;
case MessageType.end_call:
case MessageType.end_desktop:
case MessageType.end_camera:
case MessageType.end_call:
this.endMediaConnection(
conn.peer,
data.type === MessageType.end_desktop ? "desktop" : "call",
data.type === MessageType.end_desktop
? "desktop"
: data.type === MessageType.end_camera
? "camera"
: "call",
);
break;
default:
@ -769,6 +855,7 @@ export enum MessageType {
response_push_file_complete = "response_push_file_complete",
end_call = "end_call",
end_desktop = "end_desktop",
end_camera = "end_camera",
}
export enum Permission {
edit = "edit",
@ -776,6 +863,7 @@ export enum Permission {
download = "download",
desktop = "desktop",
call = "call",
camera = "camera",
}
export const PermissionLimit = {
[Permission.edit]: [
@ -795,6 +883,7 @@ export const PermissionLimit = {
[Permission.download]: [MessageType.request_getFile],
[Permission.desktop]: false,
[Permission.call]: false,
[Permission.camera]: false,
};
export function uuidv4(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {