mirror of
https://github.com/ILoveBingLu/CipherTalk.git
synced 2026-05-25 22:10:20 +08:00
feat: 增强图片查看器功能,支持高清图升级及相关参数传递
This commit is contained in:
+153
-7
@@ -2621,6 +2621,8 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
const [imageClicked, setImageClicked] = useState(false)
|
||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||
const imageClickTimerRef = useRef<number | null>(null)
|
||||
const imageRecoveringRef = useRef(false)
|
||||
const lastRecoverTriedPathRef = useRef<string | null>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -2784,7 +2786,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '200px 0px', // 提前 200px 开始加载
|
||||
rootMargin: '1200px 0px', // 提前加载,减少滚动到位后的等待
|
||||
threshold: 0
|
||||
}
|
||||
)
|
||||
@@ -3230,6 +3232,137 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
}
|
||||
}, [isImage, message.imageMd5, message.imageDatName, isVisible, imageCacheKey, imageLocalPath, session.username, syncVersion])
|
||||
|
||||
// 若已显示缩略图且检测到高清图可用,循环尝试升级(防止首轮时机过早)
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
if (!isVisible) return
|
||||
if (!imageLocalPath) return
|
||||
if (!imageLocalPath.toLowerCase().includes('_thumb')) return
|
||||
if (!imageHasUpdate) return
|
||||
const timer = window.setInterval(() => {
|
||||
if (!imageLoading) {
|
||||
void requestImageDecrypt(true)
|
||||
}
|
||||
}, 6000)
|
||||
|
||||
if (!imageLoading) {
|
||||
void requestImageDecrypt(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [isImage, isVisible, imageLocalPath, imageHasUpdate, imageLoading, requestImageDecrypt])
|
||||
|
||||
const handleOpenImage = useCallback(async () => {
|
||||
if (!imageLocalPath) return
|
||||
|
||||
let openPath = imageLocalPath
|
||||
let openLiveVideoPath = imageLiveVideoPath
|
||||
|
||||
if (imageHasUpdate && !imageLoading) {
|
||||
try {
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName,
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageHasUpdate(Boolean((result as { isThumb?: boolean }).isThumb))
|
||||
openPath = result.localPath
|
||||
if ((result as any).liveVideoPath) {
|
||||
setImageLiveVideoPath((result as any).liveVideoPath)
|
||||
openLiveVideoPath = (result as any).liveVideoPath
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore and fallback to current path
|
||||
}
|
||||
}
|
||||
|
||||
window.electronAPI.window.openImageViewerWindow(openPath, openLiveVideoPath, undefined, {
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
})
|
||||
}, [
|
||||
imageLocalPath,
|
||||
imageLiveVideoPath,
|
||||
imageHasUpdate,
|
||||
imageLoading,
|
||||
session.username,
|
||||
message.imageMd5,
|
||||
message.imageDatName,
|
||||
imageCacheKey,
|
||||
])
|
||||
|
||||
const recoverBrokenImagePath = useCallback(async () => {
|
||||
if (!isImage) return
|
||||
if ((!message.imageMd5 && !message.imageDatName) || !session.username) return
|
||||
if (imageRecoveringRef.current) return
|
||||
|
||||
const failedPath = imageLocalPath || '__empty__'
|
||||
if (lastRecoverTriedPathRef.current === failedPath && !imageHasUpdate) {
|
||||
return
|
||||
}
|
||||
lastRecoverTriedPathRef.current = failedPath
|
||||
imageRecoveringRef.current = true
|
||||
setImageLoading(true)
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await window.electronAPI.image.resolveCache(payload)
|
||||
if (cached.success && cached.localPath && cached.localPath !== imageLocalPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, cached.localPath)
|
||||
setImageLocalPath(cached.localPath)
|
||||
setImageHasUpdate(cached.localPath.toLowerCase().includes('_thumb'))
|
||||
setImageError(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue to force decrypt
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await window.electronAPI.image.decrypt({ ...payload, force: true })
|
||||
if (refreshed.success && refreshed.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, refreshed.localPath)
|
||||
setImageLocalPath(refreshed.localPath)
|
||||
setImageHasUpdate(Boolean((refreshed as { isThumb?: boolean }).isThumb))
|
||||
if ((refreshed as any).liveVideoPath) {
|
||||
setImageLiveVideoPath((refreshed as any).liveVideoPath)
|
||||
}
|
||||
setImageError(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// keep error state
|
||||
}
|
||||
|
||||
setImageError(true)
|
||||
} finally {
|
||||
setImageLoading(false)
|
||||
imageRecoveringRef.current = false
|
||||
}
|
||||
}, [
|
||||
isImage,
|
||||
message.imageMd5,
|
||||
message.imageDatName,
|
||||
session.username,
|
||||
imageLocalPath,
|
||||
imageHasUpdate,
|
||||
imageCacheKey
|
||||
])
|
||||
|
||||
// 自动检查转写缓存
|
||||
useEffect(() => {
|
||||
if (!isVoice || sttTranscript || sttLoading) return
|
||||
@@ -3277,6 +3410,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
if (matchesCacheKey) {
|
||||
imageDataUrlCache.set(imageCacheKey, payload.localPath)
|
||||
setImageLocalPath(payload.localPath)
|
||||
setImageHasUpdate(payload.localPath.toLowerCase().includes('_thumb'))
|
||||
setImageError(false)
|
||||
}
|
||||
})
|
||||
@@ -3436,14 +3570,26 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className="image-message"
|
||||
onClick={() => {
|
||||
if (imageLocalPath) {
|
||||
window.electronAPI.window.openImageViewerWindow(imageLocalPath, imageLiveVideoPath)
|
||||
}
|
||||
}}
|
||||
onClick={() => { void handleOpenImage() }}
|
||||
onLoad={() => setImageError(false)}
|
||||
onError={() => setImageError(true)}
|
||||
onError={() => {
|
||||
setImageError(true)
|
||||
void recoverBrokenImagePath()
|
||||
}}
|
||||
/>
|
||||
{imageHasUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="image-update-button"
|
||||
title="检测到高清图,点击更新"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void requestImageDecrypt(true)
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
{imageLiveVideoPath && (
|
||||
<div className="media-badge live">
|
||||
<LivePhotoIcon size={14} />
|
||||
|
||||
@@ -9,6 +9,9 @@ export default function ImageWindow() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const imagePath = searchParams.get('imagePath')
|
||||
const liveVideoPath = searchParams.get('liveVideoPath')
|
||||
const sessionId = searchParams.get('sessionId') || undefined
|
||||
const imageMd5 = searchParams.get('imageMd5') || undefined
|
||||
const imageDatName = searchParams.get('imageDatName') || undefined
|
||||
|
||||
// 图片列表导航状态
|
||||
const [imageList, setImageList] = useState<Array<{ imagePath: string; liveVideoPath?: string }>>([])
|
||||
@@ -18,6 +21,11 @@ export default function ImageWindow() {
|
||||
const currentImagePath = activeImage?.imagePath || imagePath
|
||||
// 多图模式下只用列表中的 liveVideoPath,不回退到 URL 参数,避免非实况图也显示实况按钮
|
||||
const currentLiveVideoPath = imageList.length > 0 ? activeImage?.liveVideoPath : liveVideoPath
|
||||
const [hdImagePath, setHdImagePath] = useState<string | null>(null)
|
||||
const [hdLiveVideoPath, setHdLiveVideoPath] = useState<string | undefined>(undefined)
|
||||
const upgradeTriedRef = useRef<string | null>(null)
|
||||
const effectiveImagePath = hdImagePath || currentImagePath
|
||||
const effectiveLiveVideoPath = hdLiveVideoPath ?? currentLiveVideoPath
|
||||
|
||||
const [scale, setScale] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
@@ -42,6 +50,45 @@ export default function ImageWindow() {
|
||||
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 })
|
||||
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
setHdImagePath(null)
|
||||
setHdLiveVideoPath(undefined)
|
||||
upgradeTriedRef.current = null
|
||||
}, [currentImagePath])
|
||||
|
||||
// 在图片查看器中再次尝试强制升级高清图
|
||||
useEffect(() => {
|
||||
if (!currentImagePath) return
|
||||
if (!sessionId) return
|
||||
if (!imageMd5 && !imageDatName) return
|
||||
|
||||
const upgradeKey = `${sessionId}|${imageMd5 || ''}|${imageDatName || ''}`
|
||||
if (upgradeTriedRef.current === upgradeKey) return
|
||||
upgradeTriedRef.current = upgradeKey
|
||||
|
||||
let cancelled = false
|
||||
window.electronAPI.image.decrypt({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
force: true
|
||||
}).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.localPath) {
|
||||
setHdImagePath(result.localPath)
|
||||
if ((result as any).liveVideoPath) {
|
||||
setHdLiveVideoPath((result as any).liveVideoPath)
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
// ignore
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [currentImagePath, sessionId, imageMd5, imageDatName])
|
||||
|
||||
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
|
||||
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
|
||||
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
|
||||
@@ -58,7 +105,7 @@ export default function ImageWindow() {
|
||||
|
||||
// 播放 Live Photo
|
||||
const handlePlayLiveVideo = useCallback(() => {
|
||||
if (currentLiveVideoPath && !isPlayingLive) {
|
||||
if (effectiveLiveVideoPath && !isPlayingLive) {
|
||||
setIsPlayingLive(true)
|
||||
// 播放视频
|
||||
if (videoRef.current) {
|
||||
@@ -66,7 +113,7 @@ export default function ImageWindow() {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}
|
||||
}, [currentLiveVideoPath, isPlayingLive])
|
||||
}, [effectiveLiveVideoPath, isPlayingLive])
|
||||
|
||||
// 视频真正开始播放(画面就绪)
|
||||
const handleVideoPlaying = useCallback(() => {
|
||||
@@ -353,7 +400,7 @@ export default function ImageWindow() {
|
||||
if (e.key === '-') handleZoomOut()
|
||||
if (e.key === 'r' || e.key === 'R') handleRotate()
|
||||
if (e.key === '0') handleReset()
|
||||
if (e.key === ' ' && currentLiveVideoPath) {
|
||||
if (e.key === ' ' && effectiveLiveVideoPath) {
|
||||
e.preventDefault()
|
||||
handlePlayLiveVideo()
|
||||
}
|
||||
@@ -362,11 +409,11 @@ export default function ImageWindow() {
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleReset, currentLiveVideoPath, isPlayingLive, handlePlayLiveVideo, goPrev, goNext])
|
||||
}, [handleReset, effectiveLiveVideoPath, isPlayingLive, handlePlayLiveVideo, goPrev, goNext])
|
||||
|
||||
const hasLiveVideo = !!currentLiveVideoPath
|
||||
const hasLiveVideo = !!effectiveLiveVideoPath
|
||||
|
||||
if (!currentImagePath) {
|
||||
if (!effectiveImagePath) {
|
||||
return (
|
||||
<div className="image-window-empty">
|
||||
<span>无效的图片路径</span>
|
||||
@@ -431,7 +478,7 @@ export default function ImageWindow() {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={currentImagePath}
|
||||
src={effectiveImagePath}
|
||||
alt="Preview"
|
||||
className={isPannable ? 'pannable' : ''}
|
||||
onLoad={handleImageLoad}
|
||||
@@ -441,7 +488,7 @@ export default function ImageWindow() {
|
||||
{hasLiveVideo && isPlayingLive && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={currentLiveVideoPath || ''}
|
||||
src={effectiveLiveVideoPath || ''}
|
||||
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
|
||||
autoPlay
|
||||
// muted={false} // Default is unmuted, explicit false for clarity
|
||||
|
||||
Vendored
+12
-1
@@ -6,6 +6,12 @@ export interface ImageListItem {
|
||||
liveVideoPath?: string
|
||||
}
|
||||
|
||||
export interface ImageViewerOpenOptions {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
minimize: () => void
|
||||
@@ -25,7 +31,12 @@ export interface ElectronAPI {
|
||||
isChatWindowOpen: () => Promise<boolean>
|
||||
closeChatWindow: () => Promise<boolean>
|
||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string, imageList?: ImageListItem[]) => Promise<void>
|
||||
openImageViewerWindow: (
|
||||
imagePath: string,
|
||||
liveVideoPath?: string,
|
||||
imageList?: ImageListItem[],
|
||||
options?: ImageViewerOpenOptions
|
||||
) => Promise<void>
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||
openBrowserWindow: (url: string, title?: string) => Promise<void>
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||
|
||||
Reference in New Issue
Block a user