Compare commits

..

3 Commits

Author SHA1 Message Date
kura
76879af0c8 新增对方id传递 2026-05-07 23:42:22 +08:00
kura
b958957ca5 桌面增加音频 2026-05-07 23:25:45 +08:00
kura
3ce1c07383 权限仅关闭修改,默认都打开 2026-05-07 23:12:06 +08:00
4 changed files with 237 additions and 70 deletions

View File

@ -14,6 +14,13 @@
> >
{{ isInboundConnected ? "被连接" : "已连接" }}: {{ isInboundConnected ? "被连接" : "已连接" }}:
<span class="connected-peer">{{ connectedPeerLabel }}</span> <span class="connected-peer">{{ connectedPeerLabel }}</span>
<Button
v-if="isInboundConnected && connectedPeerSign"
type="link"
size="small"
@click="copyPeerSign"
>复制</Button
>
</span> </span>
</div> </div>
<!-- 显示流量 丢包率--> <!-- 显示流量 丢包率-->
@ -68,7 +75,11 @@
:placeholder="!myId ? '请稍后...' : '输入对方ID'" :placeholder="!myId ? '请稍后...' : '输入对方ID'"
:disabled="isConnected || !myId" :disabled="isConnected || !myId"
/> />
<Button @click="handleConnect" :disabled="!targetId || isConnected"> <Button
@click="handleConnect"
:disabled="!targetId || isConnected"
:loading="isConnecting"
>
{{ isConnected ? "已连接" : "连接" }} {{ isConnected ? "已连接" : "连接" }}
</Button> </Button>
</div> </div>
@ -138,8 +149,10 @@ const myId = ref("");
const targetId = ref(""); const targetId = ref("");
const connectedPeerId = ref(""); const connectedPeerId = ref("");
const connectedPeerLabel = ref(""); const connectedPeerLabel = ref("");
const connectedPeerSign = ref("");
const isInboundConnected = ref(false); const isInboundConnected = ref(false);
const isConnected = ref(false); const isConnected = ref(false);
const isConnecting = ref(false);
const isDesktopActive = ref(false); const isDesktopActive = ref(false);
const isCallActive = ref(false); const isCallActive = ref(false);
const isCameraActive = ref(false); const isCameraActive = ref(false);
@ -205,6 +218,20 @@ const copyId = async () => {
} }
} }
}; };
const copyPeerSign = async () => {
if (connectedPeerSign.value) {
try {
await copyToClipboard(connectedPeerSign.value);
notification.success({
message: "ID已复制",
description: "已成功复制到剪贴板,可用于发起连接",
});
} catch {
notification.error({ message: "复制失败" });
}
}
};
// //
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@ -213,22 +240,29 @@ const copyToClipboard = async (text: string) => {
// //
const handleConnect = () => { const handleConnect = () => {
if (!targetId.value) return; if (!targetId.value) return;
isConnecting.value = true;
peer.connect(targetId.value); peer.connect(targetId.value);
}; };
const updateConnectedPeer = (peerId: string, inbound: boolean) => { const updateConnectedPeer = (peerId: string, inbound: boolean) => {
connectedPeerId.value = peerId; connectedPeerId.value = peerId;
connectedPeerLabel.value = inbound ? `${peerId.slice(0, 8)}...` : targetId.value; connectedPeerLabel.value = inbound
? `${peerId.slice(0, 8)}...`
: targetId.value;
isInboundConnected.value = inbound; isInboundConnected.value = inbound;
isConnected.value = !inbound; isConnected.value = !inbound;
isConnecting.value = false;
}; };
const clearConnectedPeer = (peerId?: string) => { const clearConnectedPeer = (peerId?: string) => {
if (peerId && connectedPeerId.value && connectedPeerId.value !== peerId) return; if (peerId && connectedPeerId.value && connectedPeerId.value !== peerId)
return;
connectedPeerId.value = ""; connectedPeerId.value = "";
connectedPeerLabel.value = ""; connectedPeerLabel.value = "";
connectedPeerSign.value = "";
isInboundConnected.value = false; isInboundConnected.value = false;
isConnected.value = false; isConnected.value = false;
isConnecting.value = false;
}; };
const isActivePeer = (peerId: string) => { const isActivePeer = (peerId: string) => {
@ -269,8 +303,8 @@ const handleReceive = async () => {
.getFile( .getFile(
false, false,
localCurrentFile.value.path.replace(rootFile.path, ""), localCurrentFile.value.path.replace(rootFile.path, ""),
remoteCurrentFile.value.path remoteCurrentFile.value.path,
) ),
); );
}); });
await Promise.all(getFileHandles); await Promise.all(getFileHandles);
@ -321,7 +355,17 @@ onMounted(() => {
}); });
}) as EventListener); }) as EventListener);
peer.on("exchange-sign", ((event: CustomEvent) => {
if (event.detail.peerId === connectedPeerId.value) {
connectedPeerSign.value = event.detail.sign;
if (isInboundConnected.value) {
connectedPeerLabel.value = event.detail.sign;
}
}
}) as EventListener);
peer.on("error", ((event: CustomEvent) => { peer.on("error", ((event: CustomEvent) => {
isConnecting.value = false;
notification.error({ notification.error({
message: "发生错误", message: "发生错误",
}); });
@ -368,7 +412,6 @@ onMounted(() => {
} }
}) as EventListener); }) as EventListener);
}); });
</script> </script>
<style scoped> <style scoped>

View File

@ -7,8 +7,15 @@
<div <div
v-if="isRemoteDesktopActive && !isDesktopCollapsed" v-if="isRemoteDesktopActive && !isDesktopCollapsed"
class="desktop-overlay" class="desktop-overlay"
@mousemove="handleOverlayMouseMove"
@mouseleave="handleOverlayMouseLeave"
> >
<div class="overlay-header"> <div
class="overlay-header"
:class="{
'fullscreen-hidden': isFullscreen && !isFullscreenHeaderVisible,
}"
>
<div class="header-left"> <div class="header-left">
<svg <svg
width="18" width="18"
@ -28,6 +35,41 @@
}}</span> }}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<button
v-if="desktopStream"
class="header-btn"
:class="{ 'sound-enabled': isDesktopSoundEnabled }"
@click="toggleDesktopSound"
:title="isDesktopSoundEnabled ? '静音' : '开启声音'"
>
<svg
v-if="isDesktopSoundEnabled"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
<svg
v-else
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
<span>{{ isDesktopSoundEnabled ? "出声" : "静音" }}</span>
</button>
<button <button
v-if="desktopStream" v-if="desktopStream"
class="header-btn" class="header-btn"
@ -759,7 +801,10 @@ const isCameraActive = ref(false);
const isCameraLocalSharing = ref(false); const isCameraLocalSharing = ref(false);
const isCallActive = ref(false); const isCallActive = ref(false);
const isFullscreen = ref(false); const isFullscreen = ref(false);
const isFullscreenHeaderVisible = ref(false);
const isCallMuted = ref(false); const isCallMuted = ref(false);
const isDesktopSoundEnabled = ref(false);
let fullscreenHeaderTimer: number | null = null;
const isDesktopCollapsed = ref(false); const isDesktopCollapsed = ref(false);
const isDesktopShareCollapsed = ref(false); const isDesktopShareCollapsed = ref(false);
const isCameraCollapsed = ref(false); const isCameraCollapsed = ref(false);
@ -851,7 +896,7 @@ const bindDesktopVideo = async () => {
if (videoRef.value.srcObject !== desktopStream.value) { if (videoRef.value.srcObject !== desktopStream.value) {
videoRef.value.srcObject = desktopStream.value; videoRef.value.srcObject = desktopStream.value;
} }
videoRef.value.muted = true; videoRef.value.muted = !isDesktopSoundEnabled.value;
videoRef.value.play().catch(() => { videoRef.value.play().catch(() => {
message.warning("浏览器阻止了自动播放,请点击桌面画面恢复播放"); message.warning("浏览器阻止了自动播放,请点击桌面画面恢复播放");
}); });
@ -1045,6 +1090,13 @@ const toggleCallMuted = () => {
} }
}; };
const toggleDesktopSound = () => {
isDesktopSoundEnabled.value = !isDesktopSoundEnabled.value;
if (videoRef.value) {
videoRef.value.muted = !isDesktopSoundEnabled.value;
}
};
const toggleFullscreen = async () => { const toggleFullscreen = async () => {
if (!videoContainer.value) return; if (!videoContainer.value) return;
try { try {
@ -1064,6 +1116,35 @@ const toggleFullscreen = async () => {
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement; isFullscreen.value = !!document.fullscreenElement;
isFullscreenHeaderVisible.value = false;
};
const handleOverlayMouseMove = (event: MouseEvent) => {
if (!isFullscreen.value) return;
if (event.clientY < 80) {
isFullscreenHeaderVisible.value = true;
if (fullscreenHeaderTimer !== null) {
clearTimeout(fullscreenHeaderTimer);
fullscreenHeaderTimer = null;
}
} else if (
isFullscreenHeaderVisible.value &&
fullscreenHeaderTimer === null
) {
fullscreenHeaderTimer = window.setTimeout(() => {
isFullscreenHeaderVisible.value = false;
fullscreenHeaderTimer = null;
}, 2000);
}
};
const handleOverlayMouseLeave = () => {
if (!isFullscreen.value) return;
if (fullscreenHeaderTimer !== null) {
clearTimeout(fullscreenHeaderTimer);
fullscreenHeaderTimer = null;
}
isFullscreenHeaderVisible.value = false;
}; };
const streamListener: EventListener = (event) => { const streamListener: EventListener = (event) => {
@ -1103,6 +1184,10 @@ onUnmounted(() => {
peer.off("call-started", callStartedListener); peer.off("call-started", callStartedListener);
peer.off("media-ended", mediaEndedListener); peer.off("media-ended", mediaEndedListener);
document.removeEventListener("fullscreenchange", handleFullscreenChange); document.removeEventListener("fullscreenchange", handleFullscreenChange);
if (fullscreenHeaderTimer !== null) {
clearTimeout(fullscreenHeaderTimer);
fullscreenHeaderTimer = null;
}
stopDurationTimer(); stopDurationTimer();
desktopStream.value?.getTracks().forEach((track) => track.stop()); desktopStream.value?.getTracks().forEach((track) => track.stop());
cameraStream.value?.getTracks().forEach((track) => track.stop()); cameraStream.value?.getTracks().forEach((track) => track.stop());
@ -1154,6 +1239,11 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.9); background: rgba(0, 0, 0, 0.9);
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0; flex-shrink: 0;
transition: opacity 0.3s ease;
}
.overlay-header.fullscreen-hidden {
opacity: 0;
pointer-events: none;
} }
.header-left { .header-left {
@ -1194,6 +1284,16 @@ onUnmounted(() => {
border-color: #f5222d; border-color: #f5222d;
color: #f5222d; color: #f5222d;
} }
.header-btn.sound-enabled {
background: rgba(0, 180, 42, 0.15);
border-color: #00b42a;
color: #00b42a;
}
.header-btn.sound-enabled:hover {
background: rgba(0, 180, 42, 0.25);
border-color: #00b42a;
color: #00d439;
}
.duration-text { .duration-text {
font-size: 12px; font-size: 12px;

View File

@ -4,86 +4,94 @@ import { Permission } from "./peer";
import { Modal } from "ant-design-vue"; import { Modal } from "ant-design-vue";
export const formatSize = (size: number): string => { export const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`; if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
return `${(size / (1024 * 1024)).toFixed(2)} MB`; return `${(size / (1024 * 1024)).toFixed(2)} MB`;
}; };
export let localCurrentFile: Ref<FileInfo> = ref(null); export let localCurrentFile: Ref<FileInfo> = ref(null);
export let remoteCurrentFile: Ref<FileInfo> = ref(null); export let remoteCurrentFile: Ref<FileInfo> = ref(null);
export const stringToBase64 = (str: string) => { export const stringToBase64 = (str: string) => {
//汉字转base64 //汉字转base64
return btoa(encodeURIComponent(str)) return btoa(encodeURIComponent(str));
} };
export const base64ToString = (base64: string) => { export const base64ToString = (base64: string) => {
//base64转汉字 //base64转汉字
return decodeURIComponent(atob(base64)) return decodeURIComponent(atob(base64));
} };
export const confirmWin = (title: string, content: string, okText: string, cancelText: string): Promise<boolean> => { export const confirmWin = (
return new Promise((resolve, reject) => { title: string,
Modal.confirm({ content: string,
title, okText: string,
content, cancelText: string,
okText, ): Promise<boolean> => {
cancelText, return new Promise((resolve, reject) => {
async onOk() { Modal.confirm({
resolve(true) title,
}, content,
onCancel() { okText,
reject(false) cancelText,
} async onOk() {
}) resolve(true);
}) },
} onCancel() {
const commonCharsCN = '的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多点行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当西从广业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决皮被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该冯价严龙飞'; reject(false);
const commonCharsEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; },
});
});
};
const commonCharsCN =
"的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多点行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当西从广业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决皮被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该冯价严龙飞";
const commonCharsEN = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export const randomChars = (num: number = 3, chars: string = commonCharsEN) => { export const randomChars = (num: number = 3, chars: string = commonCharsEN) => {
const result = []; const result = [];
for (let i = 0; i < num; i++) { for (let i = 0; i < num; i++) {
const randomIndex = Math.floor(Math.random() * chars.length); const randomIndex = Math.floor(Math.random() * chars.length);
result.push(chars[randomIndex]); result.push(chars[randomIndex]);
} }
return result.join(''); return result.join("");
} };
export const sign2peerid = (sign: string) => { export const sign2peerid = (sign: string) => {
sign = sign.replace(/[^a-zA-Z0-9]/g, '') sign = sign.replace(/[^a-zA-Z0-9]/g, "");
sign = sign + '+kuraa.cc+explorer' sign = sign + "+kuraa.cc+explorer";
return stringToBase64(sign).slice(0, 32) return stringToBase64(sign).slice(0, 32);
} };
//获取url query //获取url query
export const getUrlParam = (key: string) => { export const getUrlParam = (key: string) => {
var query = window.location.search.substring(1); var query = window.location.search.substring(1);
var vars = query.split("&"); var vars = query.split("&");
for (var i = 0; i < vars.length; i++) { for (var i = 0; i < vars.length; i++) {
var pair = vars[i].split("="); var pair = vars[i].split("=");
if (pair[0] == key) { return pair[1]; } if (pair[0] == key) {
return pair[1];
} }
return null; }
} return null;
};
// 权限设置 // 权限设置
let permissionSet: { [key in Permission]: boolean } = null; let permissionSet: { [key in Permission]: boolean } = null;
export function getPermissionSet(): { [key in Permission]: boolean } { export function getPermissionSet(): { [key in Permission]: boolean } {
if (!permissionSet) { if (!permissionSet) {
permissionSet = { permissionSet = {
edit: false, edit: false,
view: true, view: true,
download: true, download: true,
desktop: true, desktop: true,
call: false, call: true,
camera: false, camera: true,
...(getCache('permissionSet') || {}) ...(getCache("permissionSet") || {}),
}; };
} }
return permissionSet; return permissionSet;
} }
export function setPermissionSet(set: { [key in Permission]: boolean }) { export function setPermissionSet(set: { [key in Permission]: boolean }) {
cacheIt('permissionSet', set); cacheIt("permissionSet", set);
permissionSet = set; permissionSet = set;
} }
//缓存 //缓存
export function cacheIt(key: string, value: any) { export function cacheIt(key: string, value: any) {
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
} }
export function getCache(key: string) { export function getCache(key: string) {
return JSON.parse(localStorage.getItem(key) || null); return JSON.parse(localStorage.getItem(key) || null);
} }

View File

@ -114,7 +114,7 @@ class Peer extends EventTarget {
if (type === "desktop") { if (type === "desktop") {
stream = await navigator.mediaDevices.getDisplayMedia({ stream = await navigator.mediaDevices.getDisplayMedia({
video: true, video: true,
audio: false, audio: true,
}); });
} else if (type === "camera") { } else if (type === "camera") {
stream = await navigator.mediaDevices.getUserMedia({ stream = await navigator.mediaDevices.getUserMedia({
@ -231,6 +231,14 @@ class Peer extends EventTarget {
}); });
conn.on("open", () => { conn.on("open", () => {
this.send(
{
type: MessageType.exchange_sign,
data: this.sign,
},
conn,
true,
).catch(() => {});
this.dispatchEvent( this.dispatchEvent(
new CustomEvent("connection-open", { new CustomEvent("connection-open", {
detail: { peer: conn.peer, conn: conn }, detail: { peer: conn.peer, conn: conn },
@ -829,6 +837,13 @@ class Peer extends EventTarget {
: "call", : "call",
); );
break; break;
case MessageType.exchange_sign:
this.dispatchEvent(
new CustomEvent("exchange-sign", {
detail: { peerId: conn.peer, sign: remoteD },
}),
);
break;
default: default:
resData.type = MessageType.error; resData.type = MessageType.error;
resData.data = "未知消息类型"; resData.data = "未知消息类型";
@ -878,6 +893,7 @@ export enum MessageType {
end_call = "end_call", end_call = "end_call",
end_desktop = "end_desktop", end_desktop = "end_desktop",
end_camera = "end_camera", end_camera = "end_camera",
exchange_sign = "exchange_sign",
} }
export enum Permission { export enum Permission {
edit = "edit", edit = "edit",