新增摄像头
This commit is contained in:
parent
7d39a6dbe9
commit
6b5e402f3a
@ -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;
|
||||
|
||||
@ -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% {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -84,4 +86,4 @@ export function cacheIt(key: string, value: any) {
|
||||
}
|
||||
export function getCache(key: string) {
|
||||
return JSON.parse(localStorage.getItem(key) || null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,7 +380,9 @@ class Peer extends EventTarget {
|
||||
type:
|
||||
mediaType === "desktop"
|
||||
? MessageType.end_desktop
|
||||
: MessageType.end_call,
|
||||
: 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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user