diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss
index 02ead49..2da5401 100644
--- a/src/pages/ChatPage.scss
+++ b/src/pages/ChatPage.scss
@@ -1,4 +1,4 @@
-.chat-page {
+.chat-page {
display: flex;
height: 100%;
gap: 16px;
@@ -2079,90 +2079,83 @@
}
}
-/* 链接消息样式 (Link Card/App Message) */
-.link-message {
- background: var(--card-bg);
- border: 1px solid var(--border-color);
- border-radius: 8px;
- padding: 12px;
- width: 280px;
- cursor: pointer;
- transition: all 0.2s ease;
+// 视频消息样式
+.video-thumb-wrapper {
+ position: relative;
+ max-width: 300px;
+ min-width: 200px;
+ border-radius: 12px;
overflow: hidden;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+ cursor: pointer;
+ background: var(--bg-tertiary);
+ transition: transform 0.2s;
&:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- border-color: var(--primary-light);
+ transform: scale(1.02);
+
+ .video-play-button {
+ opacity: 1;
+ transform: scale(1.1);
+ }
}
- .link-header {
- display: flex;
- gap: 12px;
- align-items: flex-start;
+ .video-thumb {
+ width: 100%;
+ height: auto;
+ display: block;
}
- .link-content {
- flex: 1;
- min-width: 0;
- }
-
- .link-title {
- font-size: 14px;
- font-weight: 600;
- color: var(--text-primary);
- margin-bottom: 4px;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- line-clamp: 2;
- overflow: hidden;
- line-height: 1.4;
- }
-
- .link-desc {
- font-size: 12px;
- color: var(--text-secondary);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- line-clamp: 2;
- overflow: hidden;
- line-height: 1.4;
- }
-
- .link-icon {
- width: 40px;
- height: 40px;
- background: var(--bg-tertiary);
- border-radius: 4px;
+ .video-thumb-placeholder {
+ width: 100%;
+ aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
+ background: var(--bg-hover);
color: var(--text-tertiary);
- flex-shrink: 0;
svg {
- opacity: 0.6;
+ width: 32px;
+ height: 32px;
}
}
+
+ .video-play-button {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ opacity: 0.9;
+ transition: all 0.2s;
+ color: #fff;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
+ }
}
-/* 适配发送方的背景,使其从气泡颜色中脱离出来,看起来像个独立的卡片 */
-.message-bubble.sent .link-message {
- background: #fff;
- border: 1px solid rgba(0, 0, 0, 0.1);
+.video-placeholder,
+.video-loading,
+.video-unavailable {
+ min-width: 120px;
+ min-height: 80px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 16px;
+ border-radius: 12px;
+ background: var(--bg-tertiary);
+ color: var(--text-tertiary);
+ font-size: 13px;
- .link-title {
- color: #333;
+ svg {
+ width: 24px;
+ height: 24px;
}
+}
- .link-desc {
- color: #666;
- }
-
- .link-icon {
- background: #f5f5f5;
+.video-loading {
+ .spin {
+ animation: spin 1s linear infinite;
}
}
\ No newline at end of file
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 379bc0f..174c02f 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -1343,6 +1343,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const isSystem = isSystemMessage(message.localType)
const isEmoji = message.localType === 47
const isImage = message.localType === 3
+ const isVideo = message.localType === 43
const isVoice = message.localType === 34
const isSent = message.isSend === 1
const [senderAvatarUrl, setSenderAvatarUrl] = useState
(undefined)
@@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [voiceWaveform, setVoiceWaveform] = useState([])
const voiceAutoDecryptTriggered = useRef(false)
+ // 视频相关状态
+ const [videoLoading, setVideoLoading] = useState(false)
+ const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null)
+ const videoContainerRef = useRef(null)
+ const [isVideoVisible, setIsVideoVisible] = useState(false)
+ const [videoMd5, setVideoMd5] = useState(null)
+
+ // 解析视频 MD5
+ useEffect(() => {
+ if (!isVideo) return
+
+ console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2))
+ console.log('[Video Debug] Message keys:', Object.keys(message))
+ console.log('[Video Debug] Message:', {
+ localId: message.localId,
+ localType: message.localType,
+ hasVideoMd5: !!message.videoMd5,
+ hasContent: !!message.content,
+ hasParsedContent: !!message.parsedContent,
+ hasRawContent: !!(message as any).rawContent,
+ contentPreview: message.content?.substring(0, 200),
+ parsedContentPreview: message.parsedContent?.substring(0, 200),
+ rawContentPreview: (message as any).rawContent?.substring(0, 200)
+ })
+
+ // 优先使用数据库中的 videoMd5
+ if (message.videoMd5) {
+ console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5)
+ setVideoMd5(message.videoMd5)
+ return
+ }
+
+ // 尝试从多个可能的字段获取原始内容
+ const contentToUse = message.content || (message as any).rawContent || message.parsedContent
+ if (contentToUse) {
+ console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
+ window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => {
+ console.log('[Video Debug] Parse result:', result)
+ if (result && result.success && result.md5) {
+ console.log('[Video Debug] Parsed MD5:', result.md5)
+ setVideoMd5(result.md5)
+ } else {
+ console.error('[Video Debug] Failed to parse MD5:', result)
+ }
+ }).catch((err) => {
+ console.error('[Video Debug] Parse error:', err)
+ })
+ }
+ }, [isVideo, message.videoMd5, message.content, message.parsedContent])
+
// 加载自动转文字配置
useEffect(() => {
const loadConfig = async () => {
@@ -1838,6 +1889,62 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
}
}, [isVoice, message.localId, requestVoiceTranscript])
+ // 视频懒加载
+ useEffect(() => {
+ if (!isVideo || !videoContainerRef.current) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setIsVideoVisible(true)
+ observer.disconnect()
+ }
+ })
+ },
+ {
+ rootMargin: '200px 0px',
+ threshold: 0
+ }
+ )
+
+ observer.observe(videoContainerRef.current)
+
+ return () => observer.disconnect()
+ }, [isVideo])
+
+ // 加载视频信息
+ useEffect(() => {
+ if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return
+ if (!videoMd5) {
+ console.log('[Video Debug] No videoMd5 available yet')
+ return
+ }
+
+ console.log('[Video Debug] Loading video info for MD5:', videoMd5)
+ setVideoLoading(true)
+ window.electronAPI.video.getVideoInfo(videoMd5).then((result) => {
+ console.log('[Video Debug] getVideoInfo result:', result)
+ if (result && result.success) {
+ setVideoInfo({
+ exists: result.exists,
+ videoUrl: result.videoUrl,
+ coverUrl: result.coverUrl,
+ thumbUrl: result.thumbUrl
+ })
+ } else {
+ console.error('[Video Debug] Video info failed:', result)
+ setVideoInfo({ exists: false })
+ }
+ }).catch((err) => {
+ console.error('[Video Debug] getVideoInfo error:', err)
+ setVideoInfo({ exists: false })
+ }).finally(() => {
+ setVideoLoading(false)
+ })
+ }, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5])
+
+
// 根据设置决定是否自动转写
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
@@ -1973,6 +2080,72 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
)
}
+ // 视频消息
+ if (isVideo) {
+ const handlePlayVideo = useCallback(async () => {
+ if (!videoInfo?.videoUrl) return
+ try {
+ await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl)
+ } catch (e) {
+ console.error('打开视频播放窗口失败:', e)
+ }
+ }, [videoInfo?.videoUrl])
+
+ // 未进入可视区域时显示占位符
+ if (!isVideoVisible) {
+ return (
+
+ )
+ }
+
+ // 加载中
+ if (videoLoading) {
+ return (
+
+
+
+ )
+ }
+
+ // 视频不存在
+ if (!videoInfo?.exists || !videoInfo.videoUrl) {
+ return (
+
+ )
+ }
+
+ // 默认显示缩略图,点击打开独立播放窗口
+ const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
+ return (
+
+ {thumbSrc ? (
+

+ ) : (
+
+ )}
+
+
+ )
+ }
+
if (isVoice) {
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
const handleToggle = async () => {
diff --git a/src/pages/VideoWindow.scss b/src/pages/VideoWindow.scss
new file mode 100644
index 0000000..592439a
--- /dev/null
+++ b/src/pages/VideoWindow.scss
@@ -0,0 +1,216 @@
+.video-window-container {
+ width: 100vw;
+ height: 100vh;
+ background-color: #000;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ user-select: none;
+
+ .title-bar {
+ height: 40px;
+ min-height: 40px;
+ display: flex;
+ background: #1a1a1a;
+ padding-right: 140px;
+ position: relative;
+ z-index: 10;
+
+ .window-drag-area {
+ flex: 1;
+ height: 100%;
+ -webkit-app-region: drag;
+ }
+ }
+
+ .video-viewport {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ cursor: pointer;
+ background: #000;
+ overflow: hidden;
+ min-height: 0; // 重要:让 flex 子元素可以收缩
+
+ video {
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ }
+
+ .video-loading-overlay,
+ .video-error-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 5;
+ }
+
+ .video-error-overlay {
+ color: #ff6b6b;
+ font-size: 14px;
+ }
+
+ .spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid rgba(255, 255, 255, 0.2);
+ border-top-color: #fff;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ }
+
+ .play-overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.3);
+ opacity: 0;
+ transition: opacity 0.2s;
+ z-index: 4;
+
+ svg {
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
+ }
+ }
+
+ &:hover .play-overlay {
+ opacity: 1;
+ }
+ }
+
+ .video-controls {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent);
+ padding: 40px 16px 12px;
+ opacity: 0;
+ transition: opacity 0.25s;
+ z-index: 6;
+
+ .progress-bar {
+ height: 16px;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ margin-bottom: 8px;
+
+ .progress-track {
+ flex: 1;
+ height: 3px;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ overflow: hidden;
+ transition: height 0.15s;
+
+ .progress-fill {
+ height: 100%;
+ background: var(--primary, #4a9eff);
+ border-radius: 2px;
+ }
+ }
+
+ &:hover .progress-track {
+ height: 5px;
+ }
+ }
+
+ .controls-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .controls-left,
+ .controls-right {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ button {
+ background: transparent;
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ padding: 6px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.15);
+ }
+ }
+
+ .time-display {
+ color: rgba(255, 255, 255, 0.9);
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+ margin-left: 4px;
+ }
+
+ .volume-control {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ .volume-slider {
+ width: 60px;
+ height: 3px;
+ appearance: none;
+ -webkit-appearance: none;
+ background: rgba(255, 255, 255, 0.3);
+ border-radius: 2px;
+ cursor: pointer;
+
+ &::-webkit-slider-thumb {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 10px;
+ height: 10px;
+ background: #fff;
+ border-radius: 50%;
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ // 鼠标悬停时显示控制栏
+ &:hover .video-controls {
+ opacity: 1;
+ }
+
+ // 播放时如果鼠标不动,隐藏控制栏
+ &.hide-controls .video-controls {
+ opacity: 0;
+ }
+}
+
+.video-window-empty {
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(255, 255, 255, 0.6);
+ background-color: #000;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
diff --git a/src/pages/VideoWindow.tsx b/src/pages/VideoWindow.tsx
new file mode 100644
index 0000000..5719548
--- /dev/null
+++ b/src/pages/VideoWindow.tsx
@@ -0,0 +1,199 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react'
+import './VideoWindow.scss'
+
+export default function VideoWindow() {
+ const [searchParams] = useSearchParams()
+ const videoPath = searchParams.get('videoPath')
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [isMuted, setIsMuted] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+ const [volume, setVolume] = useState(1)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const videoRef = useRef(null)
+ const progressRef = useRef(null)
+
+ // 格式化时间
+ const formatTime = (seconds: number) => {
+ const mins = Math.floor(seconds / 60)
+ const secs = Math.floor(seconds % 60)
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+ }
+
+ //播放/暂停
+ const togglePlay = useCallback(() => {
+ if (!videoRef.current) return
+ if (isPlaying) {
+ videoRef.current.pause()
+ } else {
+ videoRef.current.play()
+ }
+ }, [isPlaying])
+
+ // 静音切换
+ const toggleMute = useCallback(() => {
+ if (!videoRef.current) return
+ videoRef.current.muted = !isMuted
+ setIsMuted(!isMuted)
+ }, [isMuted])
+
+ // 进度条点击
+ const handleProgressClick = useCallback((e: React.MouseEvent) => {
+ if (!videoRef.current || !progressRef.current) return
+ e.stopPropagation()
+ const rect = progressRef.current.getBoundingClientRect()
+ const percent = (e.clientX - rect.left) / rect.width
+ videoRef.current.currentTime = percent * duration
+ }, [duration])
+
+ // 音量调节
+ const handleVolumeChange = useCallback((e: React.ChangeEvent) => {
+ const newVolume = parseFloat(e.target.value)
+ setVolume(newVolume)
+ if (videoRef.current) {
+ videoRef.current.volume = newVolume
+ setIsMuted(newVolume === 0)
+ }
+ }, [])
+
+ // 重新播放
+ const handleReplay = useCallback(() => {
+ if (!videoRef.current) return
+ videoRef.current.currentTime = 0
+ videoRef.current.play()
+ }, [])
+
+ // 快捷键
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') window.electronAPI.window.close()
+ if (e.key === ' ') {
+ e.preventDefault()
+ togglePlay()
+ }
+ if (e.key === 'm' || e.key === 'M') toggleMute()
+ if (e.key === 'ArrowLeft' && videoRef.current) {
+ videoRef.current.currentTime -= 5
+ }
+ if (e.key === 'ArrowRight' && videoRef.current) {
+ videoRef.current.currentTime += 5
+ }
+ if (e.key === 'ArrowUp' && videoRef.current) {
+ videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1)
+ setVolume(videoRef.current.volume)
+ }
+ if (e.key === 'ArrowDown' && videoRef.current) {
+ videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1)
+ setVolume(videoRef.current.volume)
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [togglePlay, toggleMute])
+
+ if (!videoPath) {
+ return (
+
+ 无效的视频路径
+
+ )
+ }
+
+ const progress = duration > 0 ? (currentTime / duration) * 100 : 0
+
+ return (
+
+
+
+
+ {isLoading && (
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+ )
+}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index 34ee49d..e3528cf 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -9,6 +9,8 @@ export interface ElectronAPI {
completeOnboarding: () => Promise
openOnboardingWindow: () => Promise
setTitleBarOverlay: (options: { symbolColor: string }) => void
+ openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise
+ resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise
}
config: {
get: (key: string) => Promise
@@ -107,6 +109,21 @@ export interface ElectronAPI {
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
}
+ video: {
+ getVideoInfo: (videoMd5: string) => Promise<{
+ success: boolean
+ exists: boolean
+ videoUrl?: string
+ coverUrl?: string
+ thumbUrl?: string
+ error?: string
+ }>
+ parseVideoMd5: (content: string) => Promise<{
+ success: boolean
+ md5?: string
+ error?: string
+ }>
+ }
analytics: {
getOverallStatistics: (force?: boolean) => Promise<{
success: boolean
diff --git a/src/types/models.ts b/src/types/models.ts
index f557caa..46c3d42 100644
--- a/src/types/models.ts
+++ b/src/types/models.ts
@@ -33,12 +33,14 @@ export interface Message {
isSend: number | null
senderUsername: string | null
parsedContent: string
- rawContent?: string
+ rawContent?: string // 原始消息内容(保留用于兼容)
+ content?: string // 原始消息内容(XML)
imageMd5?: string
imageDatName?: string
emojiCdnUrl?: string
emojiMd5?: string
voiceDurationSeconds?: number
+ videoMd5?: string
// 引用消息
quotedContent?: string
quotedSender?: string