文件传输11-修复加入会话不稳定的问题

This commit is contained in:
celebrateyang 2025-06-05 22:57:34 +08:00
parent 481f8d0e9c
commit 7777566e7a
3 changed files with 841 additions and 112 deletions

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import SettingsCategory from '$components/settings/SettingsCategory.svelte';
import ActionButton from '$components/buttons/ActionButton.svelte';
@ -9,6 +9,72 @@
export let receivedText: string;
export let peerConnected: boolean;
let previousReceivedText = '';
let showNewMessageNotification = false;
let isNewMessage = false;
let receivedTextElement: HTMLElement;
// 监听接收文本的变化
$: if (receivedText !== previousReceivedText && receivedText && previousReceivedText !== '') {
handleNewTextReceived();
previousReceivedText = receivedText;
}
// 初始化时记录当前文本
onMount(() => {
previousReceivedText = receivedText;
});
function handleNewTextReceived() {
// 显示新消息通知
showNewMessageNotification = true;
isNewMessage = true;
// 滚动到接收文本区域
if (receivedTextElement) {
receivedTextElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
// 3秒后隐藏通知
setTimeout(() => {
showNewMessageNotification = false;
}, 3000);
// 5秒后移除新消息高亮
setTimeout(() => {
isNewMessage = false;
}, 5000);
// 可选:播放提示音
playNotificationSound();
}
function playNotificationSound() {
try {
// 创建一个短暂的提示音
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1);
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (error) {
// 如果音频播放失败,忽略错误
console.log('Audio notification not available');
}
}
function sendText(): void {
if (textContent.trim()) {
dispatch('sendText', { text: textContent });
@ -16,37 +82,43 @@
}
function clearReceivedText(): void {
isNewMessage = false;
showNewMessageNotification = false;
dispatch('clearText');
}
function copyReceivedText(): void {
if (receivedText && navigator.clipboard) {
navigator.clipboard.writeText(receivedText);
isNewMessage = false;
}
}
function dismissNotification() {
showNewMessageNotification = false;
isNewMessage = false;
}
</script>
<!-- 新消息通知 -->
{#if showNewMessageNotification}
<div class="new-message-notification" on:click={dismissNotification}>
<div class="notification-content">
<div class="notification-icon">📩</div>
<div class="notification-text">
<strong>收到新消息</strong>
<span>点击此处查看</span>
</div>
<button class="notification-close" on:click|stopPropagation={dismissNotification}>✕</button>
</div>
</div>
{/if}
<SettingsCategory title="文本分享" sectionId="text-sharing">
<div class="text-sharing-section">
<div class="send-text">
<h4>发送文本</h4>
<textarea
class="text-input"
bind:value={textContent}
placeholder="输入要发送的文本..."
rows="4"
disabled={!peerConnected}
></textarea> <ActionButton
id="send-text"
disabled={!peerConnected || !textContent.trim()}
click={sendText}
>
发送文本
</ActionButton>
</div>
<div class="received-text">
<h4>已接收文本</h4>
<!-- 接收文本区域 - 移到上方 -->
<div class="received-text" class:new-message={isNewMessage} bind:this={receivedTextElement}>
<h4>已接收文本 {#if isNewMessage}<span class="new-badge"></span>{/if}</h4>
{#if receivedText}
<div class="text-display">
<div class="text-content">{receivedText}</div>
@ -71,10 +143,128 @@
</div>
{/if}
</div>
<!-- 发送文本区域 - 移到下方 -->
<div class="send-text">
<h4>发送文本</h4>
<textarea
class="text-input"
bind:value={textContent}
placeholder="输入要发送的文本..."
rows="4"
disabled={!peerConnected}
></textarea>
<ActionButton
id="send-text"
disabled={!peerConnected || !textContent.trim()}
click={sendText}
>
发送文本
</ActionButton>
</div>
</div>
</SettingsCategory>
<style>
/* 新消息通知样式 */
.new-message-notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
animation: slideInRight 0.3s ease-out;
cursor: pointer;
max-width: 320px;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
}
.notification-content {
display: flex;
align-items: center;
padding: 1rem;
gap: 0.75rem;
position: relative;
}
.notification-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.notification-text {
display: flex;
flex-direction: column;
gap: 0.2rem;
color: white;
flex: 1;
}
.notification-text strong {
font-weight: 600;
font-size: 0.95rem;
}
.notification-text span {
font-size: 0.8rem;
opacity: 0.9;
}
.notification-close {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem;
border-radius: 4px;
transition: background-color 0.2s ease;
flex-shrink: 0;
}
.notification-close:hover {
background: rgba(255, 255, 255, 0.2);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 新消息徽章 */
.new-badge {
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
color: white;
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-weight: 600;
margin-left: 0.5rem;
animation: pulse 2s infinite;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.8;
}
}
.text-sharing-section {
display: flex;
flex-direction: column;
@ -94,12 +284,35 @@
transition: all 0.3s ease;
}
/* 新消息状态的特殊样式 */
.received-text.new-message {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-color: rgba(102, 126, 234, 0.3);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
from {
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
}
to {
box-shadow: 0 0 30px rgba(102, 126, 234, 0.4);
}
}
.send-text:hover, .received-text:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
.received-text.new-message:hover {
transform: translateY(-2px);
box-shadow: 0 8px 35px rgba(102, 126, 234, 0.3);
border-color: rgba(102, 126, 234, 0.4);
}
.send-text h4, .received-text h4 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
@ -108,6 +321,8 @@
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
display: flex;
align-items: center;
}
.text-input {
@ -124,7 +339,9 @@
min-height: 120px;
transition: all 0.3s ease;
backdrop-filter: blur(4px);
} .text-input:focus {
}
.text-input:focus {
outline: none;
border-color: rgba(102, 126, 234, 0.5);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1), 0 4px 12px rgba(0, 0, 0, 0.15);
@ -152,17 +369,17 @@
}
.text-content {
padding: 1.5rem;
white-space: pre-wrap;
word-wrap: break-word;
padding: 1.5rem;
margin: 0;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text);
max-height: 250px;
line-height: 1.7;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.01);
min-height: 60px;
max-height: 300px;
overflow-y: auto;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.005) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.text-content::-webkit-scrollbar {
@ -170,86 +387,168 @@
}
.text-content::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.02);
border-radius: 3px;
}
.text-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.text-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.2);
}
.text-actions {
display: flex;
gap: 0.75rem;
padding: 1.25rem;
background: rgba(255, 255, 255, 0.02);
border-top: 1px solid rgba(255, 255, 255, 0.05);
padding: 1rem 1.5rem;
background: rgba(255, 255, 255, 0.01);
justify-content: flex-end;
flex-wrap: wrap;
}
.copy-btn, .clear-btn {
padding: 0.75rem 1.5rem;
padding: 0.75rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
color: var(--text);
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s ease;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
}
.copy-btn:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-color: rgba(102, 126, 234, 0.3);
color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.2);
}
.clear-btn:hover {
background: linear-gradient(135deg, rgba(244, 67, 54, 0.2) 0%, rgba(233, 30, 99, 0.2) 100%);
background: linear-gradient(135deg, rgba(244, 67, 54, 0.1) 0%, rgba(229, 57, 53, 0.1) 100%);
border-color: rgba(244, 67, 54, 0.3);
color: #f44336;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
box-shadow: 0 6px 20px rgba(244, 67, 54, 0.2);
}
.copy-btn:active, .clear-btn:active {
transform: translateY(0) scale(0.95);
}
.empty-state {
padding: 3rem 2rem;
text-align: center;
color: var(--secondary);
font-style: italic;
padding: 3rem 2rem;
border: 2px dashed rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.01);
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.005) 100%);
transition: all 0.3s ease;
border: 2px dashed rgba(255, 255, 255, 0.1);
font-style: italic;
backdrop-filter: blur(4px);
position: relative;
overflow: hidden;
}
.empty-state:hover {
border-color: rgba(255, 255, 255, 0.2);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%);
.empty-state::before {
content: '📭';
display: block;
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent);
animation: shimmer 3s infinite;
}
@keyframes shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
/* 移动端响应式 */
@media (max-width: 768px) {
.new-message-notification {
right: 10px;
left: 10px;
max-width: none;
top: 10px;
}
.text-sharing-section {
gap: 1.5rem;
padding: 0.5rem;
}
.send-text, .received-text {
padding: 1.5rem;
gap: 1rem;
}
.text-input {
min-height: 100px;
font-size: 0.9rem;
}
.text-content {
padding: 1rem;
font-size: 0.9rem;
max-height: 200px;
}
.text-actions {
flex-direction: column;
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.copy-btn, .clear-btn {
width: 100%;
padding: 0.6rem 1rem;
font-size: 0.85rem;
flex: 1;
justify-content: center;
}
.empty-state {
padding: 2rem 1rem;
}
.empty-state::before {
font-size: 2rem;
margin-bottom: 0.75rem;
}
}
@media (max-width: 480px) {
.notification-content {
padding: 0.75rem;
gap: 0.5rem;
}
.notification-text strong {
font-size: 0.9rem;
}
.notification-text span {
font-size: 0.75rem;
}
}
</style>

View File

@ -41,7 +41,10 @@ export const clipboardState = writable({
receivingFiles: false,
transferProgress: 0,
dataChannel: null as RTCDataChannel | null,
peerConnection: null as RTCPeerConnection | null
peerConnection: null as RTCPeerConnection | null,
errorMessage: '' as string,
showError: false as boolean,
waitingForCreator: false as boolean
});
export class ClipboardManager {
@ -92,19 +95,31 @@ export class ClipboardManager {
if (typeof window !== 'undefined') {
localStorage.removeItem('clipboard_session');
}
}
private startStatusCheck(): void {
} private startStatusCheck(): void {
// 在移动端使用更频繁的状态检查以确保UI及时更新
const isMobile = typeof window !== 'undefined' &&
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
const checkInterval = isMobile ? 300 : 1000; // 移动端300ms桌面端1秒
this.statusInterval = setInterval(() => {
const wsConnected = this.ws?.readyState === WebSocket.OPEN;
const peerConnected = this.dataChannel?.readyState === 'open';
clipboardState.update(state => ({
...state,
isConnected: wsConnected,
peerConnected: peerConnected
}));
}, 1000);
// 获取当前状态避免不必要的更新
let currentState: any = {};
const unsubscribe = clipboardState.subscribe(s => currentState = s);
unsubscribe();
// 只在状态真正变化时更新
if (currentState.isConnected !== wsConnected || currentState.peerConnected !== peerConnected) {
console.log('📱 Status update:', { wsConnected, peerConnected, isMobile });
clipboardState.update(state => ({
...state,
isConnected: wsConnected,
peerConnected: peerConnected
}));
}
}, checkInterval);
}
// WebSocket management
@ -353,9 +368,7 @@ export class ClipboardManager {
const url = `${origin}/clipboard?session=${sessionId}`;
navigator.clipboard.writeText(url);
}
}
cleanup(): void {
} cleanup(): void {
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
@ -377,7 +390,10 @@ export class ClipboardManager {
sessionId: '',
isConnected: false,
peerConnected: false,
qrCodeUrl: ''
qrCodeUrl: '',
errorMessage: '',
showError: false,
waitingForCreator: false
}));
this.sharedKey = null;
@ -385,7 +401,15 @@ export class ClipboardManager {
this.clearStoredSession();
}
// WebSocket message handler
// 清除错误消息
clearError(): void {
clipboardState.update(state => ({
...state,
errorMessage: '',
showError: false,
waitingForCreator: false
}));
}// WebSocket message handler
private async handleWebSocketMessage(message: any): Promise<void> {
console.log('Handling WebSocket message:', message.type);
@ -395,7 +419,9 @@ export class ClipboardManager {
...state,
sessionId: message.sessionId,
isCreating: false,
isCreator: true
isCreator: true,
errorMessage: '',
showError: false
}));
await this.generateQRCode(message.sessionId);
this.saveSession(message.sessionId, true);
@ -405,7 +431,13 @@ export class ClipboardManager {
console.log('Session joined successfully, setting up WebRTC...');
await this.importRemotePublicKey(message.publicKey);
await this.deriveSharedKey();
clipboardState.update(state => ({ ...state, isJoining: false }));
clipboardState.update(state => ({
...state,
isJoining: false,
waitingForCreator: false,
errorMessage: '',
showError: false
}));
await this.setupWebRTC(false);
break;
@ -426,6 +458,36 @@ export class ClipboardManager {
}
break;
case 'waiting_for_creator':
console.log('Waiting for creator to reconnect...');
clipboardState.update(state => ({
...state,
isJoining: false,
waitingForCreator: true,
errorMessage: message.message || '等待创建者重新连接',
showError: true
}));
break;
case 'peer_disconnected':
console.log('Peer disconnected');
clipboardState.update(state => ({
...state,
peerConnected: false,
errorMessage: '对端已断开连接',
showError: true
}));
// 清理 WebRTC 连接但保持 WebSocket 连接
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
break;
case 'offer':
await this.handleOffer(message.offer);
break;
@ -440,20 +502,48 @@ export class ClipboardManager {
case 'error':
console.error('Server error:', message);
let errorMessage = '连接错误';
// 处理特定的错误消息
if (message.message) {
switch (message.message) {
case '会话不存在或已过期':
errorMessage = '会话已过期或不存在,请创建新会话';
this.clearStoredSession(); // 清理本地存储的过期会话
break;
case '会话已满':
errorMessage = '会话已满,无法加入';
break;
case '未知消息类型':
errorMessage = '通信协议错误';
break;
case '消息格式错误':
errorMessage = '数据格式错误';
break;
default:
errorMessage = message.message;
}
}
clipboardState.update(state => ({
...state,
isCreating: false,
isJoining: false
isJoining: false,
waitingForCreator: false,
errorMessage,
showError: true
}));
break;
}
}
// WebRTC setup and handlers
} // WebRTC setup and handlers
private async setupWebRTC(isInitiator: boolean): Promise<void> {
try {
this.peerConnection = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' } // 添加备用STUN服务器
],
iceCandidatePoolSize: 10 // 增加ICE候选池大小
});
// Update state with peer connection
@ -462,37 +552,84 @@ export class ClipboardManager {
peerConnection: this.peerConnection
}));
// 添加ICE连接状态监听
this.peerConnection.oniceconnectionstatechange = () => {
const iceState = this.peerConnection?.iceConnectionState;
console.log('🧊 ICE connection state changed:', iceState);
if (iceState === 'failed') {
console.warn('❌ ICE connection failed, attempting restart...');
this.restartIce();
}
};
this.peerConnection.onicecandidate = (event) => {
if (event.candidate && this.ws) {
console.log('📡 Sending ICE candidate:', event.candidate.type);
this.ws.send(JSON.stringify({
type: 'ice_candidate',
candidate: event.candidate
}));
}
};
this.peerConnection.onconnectionstatechange = () => {
}; this.peerConnection.onconnectionstatechange = () => {
const state = this.peerConnection?.connectionState;
console.log('🔗 Peer connection state changed:', state);
// 检测移动设备
const isMobile = typeof window !== 'undefined' &&
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
if (state === 'connected') {
console.log('🎉 Peer connected!');
clipboardState.update(state => ({ ...state, peerConnected: true }));
} else if (state === 'failed' || state === 'disconnected') {
console.warn('❌ Peer connection failed/disconnected');
// 移动端额外确认连接状态
if (isMobile) {
setTimeout(() => {
console.log('📱 Mobile peer connection confirmation');
clipboardState.update(state => ({ ...state, peerConnected: true }));
}, 150);
}
} else if (state === 'failed') {
console.warn('❌ Peer connection failed, retrying...');
clipboardState.update(state => ({ ...state, peerConnected: false }));
// 移动端使用更短的重试间隔
const retryDelay = isMobile ? 1000 : 2000;
setTimeout(() => {
if (this.peerConnection?.connectionState === 'failed') {
console.log(`🔄 Attempting to restart peer connection (${isMobile ? 'mobile' : 'desktop'})...`);
this.restartWebRTC();
}
}, retryDelay);
} else if (state === 'disconnected') {
console.warn('⚠️ Peer connection disconnected');
clipboardState.update(state => ({ ...state, peerConnected: false }));
// 移动端快速恢复尝试
if (isMobile) {
setTimeout(() => {
if (this.peerConnection?.connectionState === 'disconnected') {
console.log('📱 Mobile reconnection attempt...');
this.restartWebRTC();
}
}, 800);
}
}
};
if (isInitiator) {
};if (isInitiator) {
this.dataChannel = this.peerConnection.createDataChannel('files', {
ordered: true
ordered: true,
maxRetransmits: 3 // 增加重传次数
});
this.setupDataChannel();
// 为移动端增加延迟确保ICE candidates收集完成
await new Promise(resolve => setTimeout(resolve, 500));
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
console.log('📤 Sending offer to peer...');
if (this.ws) {
this.ws.send(JSON.stringify({
type: 'offer',
@ -501,16 +638,59 @@ export class ClipboardManager {
}
} else {
this.peerConnection.ondatachannel = (event) => {
console.log('📥 Data channel received');
this.dataChannel = event.channel;
this.setupDataChannel();
};
}
} catch (error) {
console.error('Error setting up WebRTC:', error);
// 添加错误恢复
setTimeout(() => {
console.log('🔄 Retrying WebRTC setup...');
this.setupWebRTC(isInitiator);
}, 3000);
}
}
private setupDataChannel(): void {
// 添加ICE重启方法
private async restartIce(): Promise<void> {
try {
if (this.peerConnection && this.peerConnection.connectionState !== 'closed') {
console.log('🔄 Restarting ICE...');
await this.peerConnection.restartIce();
}
} catch (error) {
console.error('Error restarting ICE:', error);
}
}
// 添加WebRTC重启方法
private async restartWebRTC(): Promise<void> {
try {
console.log('🔄 Restarting WebRTC connection...');
// 清理现有连接
if (this.dataChannel) {
this.dataChannel.close();
this.dataChannel = null;
}
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
// 获取当前状态以确定是否为发起者
let currentState: any = {};
const unsubscribe = clipboardState.subscribe(s => currentState = s);
unsubscribe();
// 重新建立连接
await this.setupWebRTC(currentState.isCreator);
} catch (error) {
console.error('Error restarting WebRTC:', error);
}
} private setupDataChannel(): void {
if (!this.dataChannel) return;
// Update state with data channel
@ -521,7 +701,18 @@ export class ClipboardManager {
this.dataChannel.onopen = () => {
console.log('🎉 Data channel opened!');
// 立即更新状态,不等待状态检查间隔
clipboardState.update(state => ({ ...state, peerConnected: true }));
// 移动端额外的状态确认延迟确保UI有足够时间响应
const isMobile = typeof window !== 'undefined' &&
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
if (isMobile) {
setTimeout(() => {
console.log('📱 Mobile connection confirmation');
clipboardState.update(state => ({ ...state, peerConnected: true }));
}, 200);
}
};
this.dataChannel.onclose = () => {
@ -531,6 +722,17 @@ export class ClipboardManager {
this.dataChannel.onerror = (error) => {
console.error('Data channel error:', error);
// 移动端错误恢复机制
const isMobile = typeof window !== 'undefined' &&
(/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent));
if (isMobile) {
console.log('📱 Mobile data channel error, attempting recovery...');
setTimeout(() => {
if (this.dataChannel?.readyState === 'open') {
clipboardState.update(state => ({ ...state, peerConnected: true }));
}
}, 500);
}
}; this.dataChannel.onmessage = async (event) => {
console.log('📨 Data channel message received:', event.data);
try {

View File

@ -48,6 +48,11 @@
let dataChannel: RTCDataChannel | null = null;
let peerConnection: RTCPeerConnection | null = null;
// Error handling state
let errorMessage = '';
let showError = false;
let waitingForCreator = false;
// Clipboard manager instance
let clipboardManager: ClipboardManager; // Subscribe to clipboard state
$: if (clipboardManager) {
@ -71,6 +76,9 @@
transferProgress = state.transferProgress;
dataChannel = state.dataChannel;
peerConnection = state.peerConnection;
errorMessage = state.errorMessage;
showError = state.showError;
waitingForCreator = state.waitingForCreator;
});
}
@ -143,10 +151,15 @@
clipboardManager?.sendText(text);
} else {
console.log('No text to send');
}
} function handleClearText() {
} } function handleClearText() {
clipboardState.update(state => ({ ...state, receivedText: '' }));
}// Lifecycle functions
}
function handleClearError() {
clipboardManager?.clearError();
}
// Lifecycle functions
onMount(async () => {
clipboardManager = new ClipboardManager();
@ -171,27 +184,37 @@
<meta property="description" content={$t("clipboard.description")} />
</svelte:head>
<div class="clipboard-container"> <div class="clipboard-header">
<div class="clipboard-container">
<div class="clipboard-header">
<h1>{$t("clipboard.title")}</h1>
<div class="description-container">
<p class="description-main">{$t("clipboard.description")}</p>
<p class="description-subtitle">{$t("clipboard.description_subtitle")}</p>
</div> </div>
<!-- Error Message Display -->
{#if showError && errorMessage}
<div class="error-notification" class:waiting={waitingForCreator}>
<div class="error-content">
<div class="error-icon">
{#if waitingForCreator}
<div class="spinner"></div>
{:else}
⚠️
{/if}
</div> <div class="error-text">
<strong>{waitingForCreator ? '等待中' : '提示'}</strong>
<p>{errorMessage}</p>
</div>
<button class="error-close" on:click={handleClearError} aria-label="关闭">
</button>
</div>
</div>
</div><!-- Session Management Component -->
<SessionManager
{sessionId}
{isConnected}
{isCreating}
{isJoining}
{isCreator}
{peerConnected}
{qrCodeUrl}
on:createSession={handleCreateSession}
on:joinSession={handleJoinSession}
on:shareSession={handleShareSession}
on:cleanup={handleCleanup}
bind:joinCode
/> {#if isConnected && peerConnected} <!-- Connection Status Indicator -->
{/if}
{#if isConnected && peerConnected}
<!-- Connection Status Indicator -->
<div class="session-status">
<div class="status-badge">
<div class="status-dot connected"></div>
@ -199,13 +222,13 @@
</div>
</div>
<!-- Tab Navigation Component -->
<!-- Tab Navigation Component - Moved to top -->
<TabNavigation
{activeTab}
on:tabChange={handleTabChange}
/>
<!-- Content Area -->
<!-- Content Area - Moved to top -->
<div class="tab-content">
<!-- File Transfer Component -->
{#if activeTab === 'files'}
@ -233,8 +256,41 @@
on:sendText={handleSendText}
on:clearText={handleClearText}
bind:textContent
/> {/if}
/>
{/if}
</div>
<!-- Session Management Section - Moved to bottom -->
<div class="session-management-section">
<div class="session-info">
<h3>会话管理</h3>
<p>会话ID: <code>{sessionId}</code></p>
<div class="session-actions">
<button class="btn-secondary" on:click={handleShareSession}>
分享会话
</button>
<button class="btn-secondary danger" on:click={handleCleanup}>
断开连接
</button>
</div>
</div>
</div>
{:else}
<!-- Session Management Component - Show when not connected -->
<SessionManager
{sessionId}
{isConnected}
{isCreating}
{isJoining}
{isCreator}
{peerConnected}
{qrCodeUrl}
on:createSession={handleCreateSession}
on:joinSession={handleJoinSession}
on:shareSession={handleShareSession}
on:cleanup={handleCleanup}
bind:joinCode
/>
{/if}
</div>
@ -302,9 +358,106 @@
font-weight: 400 !important;
opacity: 0.7 !important;
margin: 0;
max-width: 600px;
line-height: 1.4;
} /* Enhanced session status */
max-width: 600px; line-height: 1.4;
}
/* Error notification styles */
.error-notification {
margin: 1rem 0;
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
backdrop-filter: blur(8px);
animation: slideIn 0.3s ease-out;
}
.error-notification.waiting {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
}
.error-content {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.error-icon {
flex-shrink: 0;
font-size: 1.2rem;
margin-top: 0.1rem;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(34, 197, 94, 0.3);
border-top: 2px solid #22c55e;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.error-text {
flex: 1;
}
.error-text strong {
color: #ef4444;
font-weight: 600;
display: block;
margin-bottom: 0.25rem;
}
.error-notification.waiting .error-text strong {
color: #22c55e;
}
.error-text p {
color: var(--text);
margin: 0;
font-size: 0.9rem;
opacity: 0.9;
}
.error-close {
background: none;
border: none;
color: var(--text);
font-size: 1.2rem;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease;
padding: 0;
line-height: 1;
flex-shrink: 0;
}
.error-close:hover {
opacity: 1;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Enhanced session status */
.session-status {
display: flex;
align-items: center;
@ -357,11 +510,86 @@
backdrop-filter: blur(8px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
position: relative;
overflow: visible; /* 允许通知显示在容器外 */
}
.tab-content:hover {
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.08);
border-color: rgba(255, 255, 255, 0.12); }
border-color: rgba(255, 255, 255, 0.12);
}
/* Session management section styling */
.session-management-section {
margin-top: 1.5rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 15px;
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
}
.session-info {
text-align: center;
}
.session-info h3 {
color: var(--text);
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.session-info p {
color: var(--subtext);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.session-info code {
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 6px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
color: var(--accent);
font-weight: 500;
}
.session-actions {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
padding: 0.6rem 1.2rem;
color: var(--text);
font-weight: 500;
transition: all 0.3s ease;
cursor: pointer;
font-size: 0.9rem;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
}
.btn-secondary.danger {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.btn-secondary.danger:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
}
/* Enhanced card-like sections */
:global(.card) {