feat: 新增了聊天页面播放视频的功能

This commit is contained in:
xuncha
2026-01-18 23:19:58 +08:00
parent d4c7e86e05
commit 240514f1e5
11 changed files with 1245 additions and 51 deletions

View File

@@ -1956,4 +1956,85 @@
width: 14px;
height: 14px;
}
}
// 视频消息样式
.video-thumb-wrapper {
position: relative;
max-width: 300px;
min-width: 200px;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background: var(--bg-tertiary);
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
.video-play-button {
opacity: 1;
transform: scale(1.1);
}
}
.video-thumb {
width: 100%;
height: auto;
display: block;
}
.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);
svg {
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));
}
}
.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;
svg {
width: 24px;
height: 24px;
}
}
.video-loading {
.spin {
animation: spin 1s linear infinite;
}
}

View File

@@ -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<string | undefined>(undefined)
@@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
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<HTMLDivElement>(null)
const [isVideoVisible, setIsVideoVisible] = useState(false)
const [videoMd5, setVideoMd5] = useState<string | null>(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)
@@ -1968,6 +2075,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 (
<div className="video-placeholder" ref={videoContainerRef}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
)
}
// 加载中
if (videoLoading) {
return (
<div className="video-loading" ref={videoContainerRef}>
<Loader2 size={20} className="spin" />
</div>
)
}
// 视频不存在
if (!videoInfo?.exists || !videoInfo.videoUrl) {
return (
<div className="video-unavailable" ref={videoContainerRef}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
<span></span>
</div>
)
}
// 默认显示缩略图,点击打开独立播放窗口
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
return (
<div className="video-thumb-wrapper" ref={videoContainerRef} onClick={handlePlayVideo}>
{thumbSrc ? (
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
) : (
<div className="video-thumb-placeholder">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
)}
<div className="video-play-button">
<Play size={32} fill="white" />
</div>
</div>
)
}
if (isVoice) {
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
const handleToggle = async () => {

216
src/pages/VideoWindow.scss Normal file
View File

@@ -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); }
}

199
src/pages/VideoWindow.tsx Normal file
View File

@@ -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<string | null>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const progressRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="video-window-empty">
<span></span>
</div>
)
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="video-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
</div>
<div className="video-viewport" onClick={togglePlay}>
{isLoading && (
<div className="video-loading-overlay">
<div className="spinner"></div>
</div>
)}
{error && (
<div className="video-error-overlay">
<span>{error}</span>
</div>
)}
<video
ref={videoRef}
src={videoPath}
onLoadedMetadata={(e) => {
const video = e.currentTarget
setDuration(video.duration)
setIsLoading(false)
// 根据视频尺寸调整窗口大小
if (video.videoWidth && video.videoHeight) {
window.electronAPI.window.resizeToFitVideo(video.videoWidth, video.videoHeight)
}
}}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
onError={() => {
setError('视频加载失败')
setIsLoading(false)
}}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
autoPlay
/>
{!isPlaying && !isLoading && !error && (
<div className="play-overlay">
<Play size={64} fill="white" />
</div>
)}
<div className="video-controls" onClick={(e) => e.stopPropagation()}>
<div
className="progress-bar"
ref={progressRef}
onClick={handleProgressClick}
>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
</div>
</div>
<div className="controls-row">
<div className="controls-left">
<button onClick={togglePlay} title={isPlaying ? '暂停 (空格)' : '播放 (空格)'}>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
<button onClick={handleReplay} title="重新播放">
<RotateCcw size={16} />
</button>
<span className="time-display">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className="controls-right">
<div className="volume-control">
<button onClick={toggleMute} title={isMuted ? '取消静音 (M)' : '静音 (M)'}>
{isMuted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}