1422 lines
35 KiB
Vue
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>
|