p2p-explorer-web/src/pages/file/item/desptopView.vue
2026-05-07 22:02:25 +08:00

1422 lines
35 KiB
Vue

<template>
<div>
<audio ref="audioRef" autoplay playsinline></audio>
<!-- Desktop Overlay -->
<Transition name="panel-slide">
<div
v-if="isRemoteDesktopActive && !isDesktopCollapsed"
class="desktop-overlay"
>
<div class="overlay-header">
<div class="header-left">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
<span>远程桌面</span>
<span class="duration-text" v-if="desktopStream">{{
callDurationStr
}}</span>
</div>
<div class="header-right">
<button
v-if="desktopStream"
class="header-btn"
@click="toggleFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'"
>
<svg
v-if="!isFullscreen"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="14" y1="10" x2="21" y2="3" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<span>{{ isFullscreen ? "退出" : "全屏" }}</span>
</button>
<button class="header-btn danger" @click="endDesktop">
<svg
width="16"
height="16"
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>
<span>结束共享</span>
</button>
</div>
</div>
<div class="overlay-body" ref="videoContainer">
<div v-if="desktopStream" class="video-wrapper">
<video ref="videoRef" autoplay playsinline></video>
</div>
<div v-else class="loading-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="31.4 31.4"
stroke-linecap="round"
/>
</svg>
<span>等待桌面共享...</span>
</div>
</div>
<button
class="desktop-side-toggle"
@click="toggleDesktopCollapse"
title="收起桌面"
aria-label="收起桌面"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</Transition>
<!-- Desktop Collapsed Tab (right side) -->
<Transition name="tab-slide">
<div
v-if="isRemoteDesktopActive && isDesktopCollapsed"
class="desktop-collapsed-tab"
:class="{ 'is-stacked': isCallActive && !isCallCollapsed }"
@click="toggleDesktopCollapse"
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"
>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8" />
<path d="M12 16v4" />
</svg>
</div>
</Transition>
<!-- Local Desktop Sharing Indicator -->
<Transition name="panel-slide">
<div
v-if="isDesktopLocalSharing && !isDesktopShareCollapsed"
class="desktop-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"
>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8" />
<path d="M12 16v4" />
</svg>
<span>桌面正在被预览</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleDesktopShareCollapse"
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="endDesktop" 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="isDesktopLocalSharing && isDesktopShareCollapsed"
class="desktop-sharing-tab"
:class="{ 'is-stacked': isCallActive && !isCallCollapsed }"
@click="toggleDesktopShareCollapse"
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"
>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M8 20h8" />
<path d="M12 16v4" />
</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 }"
>
<div class="call-panel-header">
<div class="panel-header-left">
<span class="panel-header-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</span>
<span>语音通话请求</span>
</div>
</div>
<div class="call-panel-body">
<div class="call-status-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</div>
<div class="call-status-text">对方请求语音通话</div>
</div>
<div class="incoming-call-actions">
<button
class="incoming-action-btn reject"
:disabled="isIncomingCallHandling"
@click="rejectIncomingCall"
>
拒绝
</button>
<button
class="incoming-action-btn accept"
:disabled="isIncomingCallHandling"
@click="acceptIncomingCall"
>
接听
</button>
</div>
</div>
</Transition>
<!-- Call-Only Floating Panel (expanded) -->
<Transition name="panel-slide">
<div
v-if="isCallActive && !isCallCollapsed && !incomingCallRequest"
class="call-floating-panel"
:class="{ 'is-stacked': isDesktopLocalSharing }"
>
<div class="call-panel-header">
<div class="panel-header-left">
<span class="panel-header-icon">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</span>
<span>语音通话</span>
</div>
<div class="panel-header-actions">
<button
class="panel-icon-btn"
@click="toggleCallCollapse"
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="endCall" 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="call-panel-body">
<div class="call-status-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</div>
<div class="call-status-text">
{{ callStream ? "通话中" : "正在建立连接..." }}
</div>
<div class="call-duration">{{ callDurationStr }}</div>
</div>
<div class="call-panel-footer">
<button
class="control-btn mute-btn"
:class="{ 'is-muted': isCallMuted }"
@click="toggleCallMuted"
:title="isCallMuted ? '取消静音' : '静音'"
>
<svg
v-if="!isCallMuted"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
<svg
v-else
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="1" y1="1" x2="23" y2="23" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<path d="M15 9.34V4a3 3 0 0 0-5.94-.6" />
<path d="M19 10v2a7 7 0 0 1-8.93 6.69" />
<path d="M12 19v4" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
</button>
<button
class="control-btn end-call-btn"
@click="endCall"
title="结束通话"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</button>
</div>
</div>
</Transition>
<!-- Call-Only Collapsed Button (right side) -->
<Transition name="tab-slide">
<div
v-if="isCallCollapsed && isCallActive"
class="call-collapsed-btn"
:class="{ 'is-stacked': isDesktopLocalSharing && !isDesktopShareCollapsed }"
@click="toggleCallCollapse"
title="展开通话"
>
<div class="call-collapsed-pulse"></div>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
/>
</svg>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, onMounted, onUnmounted } from "vue";
import { peer } from "../utils/peer";
import { message } from "ant-design-vue";
const props = defineProps<{
peerId: string;
}>();
interface StreamEvent extends CustomEvent {
detail: {
stream: MediaStream;
type: "desktop" | "call";
peerId: string;
};
}
interface MediaEndedEvent extends CustomEvent {
detail: {
type: "desktop" | "call";
peerId: string;
};
}
interface MediaStartedEvent extends CustomEvent {
detail: {
peerId: string;
role?: "viewer" | "sharer" | "caller";
};
}
interface MediaRequestEvent extends CustomEvent {
detail: {
peerId: string;
type: "desktop" | "call";
accept: () => Promise<void> | void;
reject: () => void;
};
}
interface IncomingCallRequest {
peerId: string;
accept: () => Promise<void> | void;
reject: () => void;
}
const videoRef = 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 callStream = ref<MediaStream | null>(null);
const isDesktopActive = ref(false);
const isDesktopLocalSharing = ref(false);
const isCallActive = ref(false);
const isFullscreen = ref(false);
const isCallMuted = ref(false);
const isDesktopCollapsed = ref(false);
const isDesktopShareCollapsed = ref(false);
const isCallCollapsed = ref(false);
const isIncomingCallHandling = ref(false);
const callDuration = ref(0);
let durationTimer: number | null = null;
const isRemoteDesktopActive = computed(
() => isDesktopActive.value && !isDesktopLocalSharing.value,
);
const callDurationStr = computed(() => {
const mins = Math.floor(callDuration.value / 60);
const secs = callDuration.value % 60;
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
});
const startDurationTimer = () => {
if (durationTimer !== null) return;
callDuration.value = 0;
durationTimer = window.setInterval(() => {
callDuration.value++;
}, 1000);
};
const stopDurationTimer = () => {
if (durationTimer !== null) {
clearInterval(durationTimer);
durationTimer = null;
}
callDuration.value = 0;
};
const isCurrentPeer = (peerId: string) => {
if (peerId === props.peerId || peerId === activeMediaPeerId.value) {
activeMediaPeerId.value = peerId;
return true;
}
if (!activeMediaPeerId.value) {
activeMediaPeerId.value = peerId;
return true;
}
return false;
};
const getMediaPeerId = () => activeMediaPeerId.value || props.peerId;
const resetPeerWhenIdle = () => {
if (
!isDesktopActive.value &&
!isDesktopLocalSharing.value &&
!isCallActive.value
) {
activeMediaPeerId.value = "";
}
};
const bindDesktopVideo = async () => {
await nextTick();
if (!videoRef.value || !desktopStream.value) return;
if (videoRef.value.srcObject !== desktopStream.value) {
videoRef.value.srcObject = desktopStream.value;
}
videoRef.value.muted = true;
videoRef.value.play().catch(() => {
message.warning("浏览器阻止了自动播放,请点击桌面画面恢复播放");
});
};
const toggleDesktopCollapse = async () => {
isDesktopCollapsed.value = !isDesktopCollapsed.value;
if (!isDesktopCollapsed.value) {
await bindDesktopVideo();
}
};
const toggleDesktopShareCollapse = () => {
isDesktopShareCollapsed.value = !isDesktopShareCollapsed.value;
};
const toggleCallCollapse = () => {
isCallCollapsed.value = !isCallCollapsed.value;
};
const handleMediaRequest = (event: MediaRequestEvent) => {
const { peerId, type, accept, reject } = event.detail;
if (type !== "call" || !isCurrentPeer(peerId)) return;
incomingCallRequest.value = {
peerId,
accept,
reject,
};
isCallCollapsed.value = false;
};
const acceptIncomingCall = async () => {
if (!incomingCallRequest.value || isIncomingCallHandling.value) return;
isIncomingCallHandling.value = true;
try {
await incomingCallRequest.value.accept();
incomingCallRequest.value = null;
} finally {
isIncomingCallHandling.value = false;
}
};
const rejectIncomingCall = () => {
if (!incomingCallRequest.value || isIncomingCallHandling.value) return;
incomingCallRequest.value.reject();
incomingCallRequest.value = null;
resetPeerWhenIdle();
};
const handleStream = async (event: StreamEvent) => {
const { stream: remoteStream, type } = event.detail;
if (!isCurrentPeer(event.detail.peerId)) return;
if (type === "desktop") {
isDesktopActive.value = true;
isDesktopLocalSharing.value = false;
desktopStream.value = remoteStream;
isDesktopCollapsed.value = false;
await bindDesktopVideo();
} else if (type === "call") {
isCallActive.value = true;
callStream.value = remoteStream;
startDurationTimer();
await nextTick();
if (audioRef.value) {
audioRef.value.srcObject = remoteStream;
}
}
};
const handleDesktopStarted = (event: MediaStartedEvent) => {
if (!isCurrentPeer(event.detail.peerId)) return;
if (event.detail.role === "sharer") {
isDesktopActive.value = true;
isDesktopLocalSharing.value = true;
isDesktopShareCollapsed.value = false;
return;
}
isDesktopActive.value = true;
isDesktopLocalSharing.value = false;
isDesktopCollapsed.value = false;
};
const handleCallStarted = (event: MediaStartedEvent) => {
if (!isCurrentPeer(event.detail.peerId)) return;
isCallActive.value = true;
isCallCollapsed.value = false;
incomingCallRequest.value = null;
startDurationTimer();
};
const handleMediaEnded = (event: MediaEndedEvent) => {
const { type } = event.detail;
if (!isCurrentPeer(event.detail.peerId)) return;
if (type === "desktop") {
isDesktopActive.value = false;
isDesktopLocalSharing.value = false;
desktopStream.value = null;
isDesktopCollapsed.value = false;
isDesktopShareCollapsed.value = false;
if (videoRef.value) {
videoRef.value.srcObject = null;
}
message.info("远程桌面共享已结束");
if (!isCallActive.value) {
stopDurationTimer();
}
} else if (type === "call") {
isCallActive.value = false;
callStream.value = null;
isCallCollapsed.value = false;
incomingCallRequest.value = null;
if (audioRef.value) {
audioRef.value.srcObject = null;
}
stopDurationTimer();
message.info("语音通话已结束");
}
resetPeerWhenIdle();
};
const endDesktop = () => {
peer.endMedia(getMediaPeerId(), "desktop");
};
const endCall = () => {
peer.endMedia(getMediaPeerId(), "call");
stopDurationTimer();
};
const toggleCallMuted = () => {
isCallMuted.value = !isCallMuted.value;
if (audioRef.value) {
audioRef.value.muted = isCallMuted.value;
}
};
const toggleFullscreen = async () => {
if (!videoContainer.value) return;
try {
if (!isFullscreen.value) {
if (videoContainer.value.requestFullscreen) {
await videoContainer.value.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
await document.exitFullscreen();
}
}
} catch {
message.error("切换全屏失败");
}
};
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
};
const streamListener: EventListener = (event) => {
handleStream(event as StreamEvent);
};
const mediaEndedListener: EventListener = (event) => {
handleMediaEnded(event as MediaEndedEvent);
};
const desktopStartedListener: EventListener = (event) => {
handleDesktopStarted(event as MediaStartedEvent);
};
const callStartedListener: EventListener = (event) => {
handleCallStarted(event as MediaStartedEvent);
};
const mediaRequestListener: EventListener = (event) => {
handleMediaRequest(event as MediaRequestEvent);
};
onMounted(() => {
peer.on("media-request", mediaRequestListener);
peer.on("stream", streamListener);
peer.on("desktop-started", desktopStartedListener);
peer.on("call-started", callStartedListener);
peer.on("media-ended", mediaEndedListener);
document.addEventListener("fullscreenchange", handleFullscreenChange);
});
onUnmounted(() => {
peer.off("media-request", mediaRequestListener);
peer.off("stream", streamListener);
peer.off("desktop-started", desktopStartedListener);
peer.off("call-started", callStartedListener);
peer.off("media-ended", mediaEndedListener);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
stopDurationTimer();
desktopStream.value?.getTracks().forEach((track) => track.stop());
callStream.value?.getTracks().forEach((track) => track.stop());
});
</script>
<style scoped>
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.panel-slide-enter-from,
.panel-slide-leave-to {
transform: translateX(100%);
}
.tab-slide-enter-active,
.tab-slide-leave-active {
transition:
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease;
}
.tab-slide-enter-from,
.tab-slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
/* Desktop Overlay - Full Screen */
.desktop-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
background: #0a0a0a;
display: flex;
flex-direction: column;
color: #fff;
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: rgba(0, 0, 0, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 500;
color: #e0e0e0;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.header-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: transparent;
color: #ccc;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
}
.header-btn.danger:hover {
background: rgba(245, 34, 45, 0.15);
border-color: #f5222d;
color: #f5222d;
}
.duration-text {
font-size: 12px;
color: #666;
font-variant-numeric: tabular-nums;
}
.overlay-body {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.desktop-side-toggle {
position: absolute;
right: 0;
top: 50%;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 56px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-right: none;
border-radius: 10px 0 0 10px;
background: rgba(0, 0, 0, 0.72);
color: #d9d9d9;
cursor: pointer;
transform: translateY(-50%);
transition: all 0.2s;
}
.desktop-side-toggle:hover {
width: 38px;
background: rgba(25, 25, 25, 0.92);
color: #fff;
}
.video-wrapper {
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: #888;
font-size: 14px;
}
.spin-icon {
animation: spin 1.5s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Desktop Call Bar */
.desktop-call-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.85);
border-top: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.call-bar-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #aaa;
}
.call-duration-text {
color: #666;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.call-bar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.bar-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: transparent;
color: #ccc;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.bar-action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bar-action-btn.is-muted {
background: rgba(250, 173, 20, 0.15);
border-color: #faad14;
color: #faad14;
}
.bar-action-btn.danger:hover {
background: rgba(245, 34, 45, 0.15);
border-color: #f5222d;
color: #f5222d;
}
.desktop-collapsed-tab {
position: fixed;
right: 16px;
bottom: 84px;
z-index: 1002;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
padding: 0;
background: linear-gradient(145deg, #1b2332, #121821);
border: none;
border-radius: 50%;
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;
}
.desktop-collapsed-tab:hover {
transform: scale(1.08);
}
.desktop-collapsed-tab.is-stacked {
bottom: 372px;
}
.desktop-collapsed-tab .sharing-status-dot {
position: absolute;
right: 7px;
top: 7px;
width: 7px;
height: 7px;
}
.desktop-sharing-panel {
position: fixed;
right: 24px;
bottom: 24px;
width: 300px;
z-index: 1002;
overflow: hidden;
color: #fff;
background: linear-gradient(145deg, #1b2332, #121821);
border-radius: 14px;
box-shadow:
0 8px 28px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(255, 255, 255, 0.08);
}
.sharing-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 14px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.sharing-panel-title {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
font-weight: 500;
color: #e8e8e8;
}
.sharing-panel-title svg {
color: #40a9ff;
}
.sharing-panel-body {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 14px;
font-size: 13px;
color: #cfcfcf;
}
.sharing-status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #52c41a;
box-shadow: 0 0 0 5px rgba(82, 196, 26, 0.14);
flex-shrink: 0;
}
.desktop-sharing-tab {
position: fixed;
right: 16px;
bottom: 84px;
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;
}
.desktop-sharing-tab:hover {
transform: scale(1.08);
}
.desktop-sharing-tab.is-stacked {
bottom: 372px;
}
.desktop-sharing-tab .sharing-status-dot {
position: absolute;
right: 7px;
top: 7px;
width: 7px;
height: 7px;
}
/* Call-Only Floating Panel (expanded) */
.call-floating-panel {
position: fixed;
right: 24px;
bottom: 24px;
width: 320px;
z-index: 1002;
background: linear-gradient(145deg, #1a1a2e, #16213e);
border-radius: 16px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.06);
overflow: hidden;
color: #fff;
}
.call-floating-panel.is-stacked {
bottom: 148px;
}
.incoming-call-panel .call-panel-body {
padding-bottom: 20px;
}
.incoming-call-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 0 24px 22px;
}
.incoming-action-btn {
height: 42px;
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.incoming-action-btn:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.incoming-action-btn.reject {
background: rgba(255, 255, 255, 0.1);
color: #d9d9d9;
}
.incoming-action-btn.reject:hover:not(:disabled) {
background: rgba(245, 34, 45, 0.2);
color: #ff7875;
}
.incoming-action-btn.accept {
background: #52c41a;
}
.incoming-action-btn.accept:hover:not(:disabled) {
background: #73d13d;
}
.call-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.panel-header-left {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
font-weight: 500;
}
.panel-header-icon {
display: flex;
color: #52c41a;
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.panel-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: #888;
cursor: pointer;
transition: all 0.2s;
}
.panel-icon-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.panel-icon-btn:last-child:hover {
background: rgba(245, 34, 45, 0.2);
color: #f5222d;
}
.call-panel-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px 24px;
gap: 8px;
}
.call-status-icon {
width: 72px;
height: 72px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(82, 196, 26, 0.1);
border-radius: 50%;
color: #52c41a;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.3);
}
50% {
box-shadow: 0 0 0 12px rgba(82, 196, 26, 0);
}
}
.call-status-text {
font-size: 15px;
color: #ddd;
margin-top: 8px;
}
.call-duration {
font-size: 32px;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: #fff;
letter-spacing: 2px;
margin-top: 4px;
}
.call-panel-footer {
display: flex;
justify-content: center;
gap: 24px;
padding: 16px 24px 20px;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.25s;
}
.mute-btn {
background: rgba(255, 255, 255, 0.08);
color: #ccc;
}
.mute-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.mute-btn.is-muted {
background: rgba(250, 173, 20, 0.2);
color: #faad14;
}
.mute-btn.is-muted:hover {
background: rgba(250, 173, 20, 0.3);
}
.end-call-btn {
background: #f5222d;
color: #fff;
transform: rotate(135deg);
}
.end-call-btn:hover {
background: #ff4d4f;
transform: rotate(135deg) scale(1.05);
}
/* Call Collapsed Button (right edge, bottom) */
.call-collapsed-btn {
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(135deg, #1a1a2e, #16213e);
color: #52c41a;
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;
}
.call-collapsed-btn:hover {
transform: scale(1.08);
box-shadow:
0 6px 24px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.12);
}
.call-collapsed-btn.is-stacked {
bottom: 148px;
}
.call-collapsed-pulse {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: rgba(82, 196, 26, 0.15);
animation: callPulse 2s ease-in-out infinite;
pointer-events: none;
}
@keyframes callPulse {
0%,
100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.3);
opacity: 0;
}
}
@media screen and (max-width: 768px) {
.call-floating-panel {
right: 12px;
bottom: 12px;
width: 280px;
}
.call-panel-body {
padding: 24px 20px 20px;
}
.call-duration {
font-size: 26px;
}
.control-btn {
width: 46px;
height: 46px;
}
.overlay-header {
padding: 10px 14px;
}
.header-btn span {
display: none;
}
.desktop-call-bar {
flex-direction: column;
gap: 8px;
padding: 10px 14px;
}
.desktop-collapsed-tab {
width: 48px;
height: 44px;
padding: 0;
}
.call-collapsed-btn {
right: 12px;
bottom: 16px;
width: 42px;
height: 42px;
}
}
</style>