支持朋友圈图片解密;视频解密;实况渲染

This commit is contained in:
cc
2026-02-16 23:31:52 +08:00
parent 75b056d5ba
commit b4248d4a12
21 changed files with 5748 additions and 225 deletions

View File

@@ -14,12 +14,21 @@
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out;
&.dragging {
transition: none;
}
}
.preview-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: fit-content;
}
.image-preview-close {
position: absolute;
bottom: 40px;
@@ -44,3 +53,38 @@
transform: translateX(-50%) scale(1.1);
}
}
.live-photo-btn {
position: absolute;
top: 15px;
right: 15px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.2s;
z-index: 10000;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
&.active {
background: var(--accent-color, #007aff);
border-color: transparent;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
span {
font-size: 14px;
font-weight: 500;
}
}

View File

@@ -1,36 +1,41 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { X } from 'lucide-react'
import { LivePhotoIcon } from './LivePhotoIcon'
import { createPortal } from 'react-dom'
import './ImagePreview.scss'
interface ImagePreviewProps {
src: string
isVideo?: boolean
liveVideoPath?: string
onClose: () => void
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, isVideo, liveVideoPath, onClose }) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [showLive, setShowLive] = useState(false)
const dragStart = useRef({ x: 0, y: 0 })
const positionStart = useRef({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
}, [])
}, [showLive])
// 开始拖动
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (scale <= 1) return
if (showLive || scale <= 1) return
e.preventDefault()
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { ...position }
}, [scale, position])
}, [scale, position, showLive])
// 拖动中
const handleMouseMove = useCallback((e: React.MouseEvent) => {
@@ -79,19 +84,62 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
<div
className="preview-content"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
position: 'relative',
transform: `translate(${position.x}px, ${position.y}px)`,
width: 'fit-content',
height: 'fit-content'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
onClick={(e) => e.stopPropagation()}
>
{(isVideo || showLive) ? (
<video
src={showLive ? liveVideoPath : src}
controls={!showLive}
autoPlay
loop={showLive}
className="preview-image"
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw'
}}
/>
) : (
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw',
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
)}
{liveVideoPath && !isVideo && (
<button
className={`live-photo-btn ${showLive ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation()
setShowLive(!showLive)
}}
title={showLive ? "显示照片" : "播放实况"}
>
<LivePhotoIcon size={20} />
<span></span>
</button>
)}
</div>
<button className="image-preview-close" onClick={onClose}>
<X size={20} />
</button>

View File

@@ -809,6 +809,60 @@
}
}
.video-badge-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
.video-badge {
width: 44px;
height: 44px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
svg {
fill: white;
opacity: 0.9;
}
}
.decrypting-badge {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
padding: 8px 16px;
border-radius: 20px;
display: flex;
align-items: center;
gap: 8px;
color: white;
font-size: 13px;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.2);
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
.spin-icon {
animation: spin 1s linear infinite;
}
}
}
&:hover {
.download-btn-overlay {
opacity: 1;
@@ -1207,4 +1261,14 @@
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}

View File

@@ -34,57 +34,218 @@ interface SnsPost {
rawXml?: string // 原始 XML 数据
}
const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string) => void }) => {
const MediaItem = ({ media, onPreview }: { media: any; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void }) => {
const [error, setError] = useState(false)
const [resolvedSrc, setResolvedSrc] = useState<string>('')
const [thumbSrc, setThumbSrc] = useState<string>('') // 缩略图
const [videoPath, setVideoPath] = useState<string>('') // 视频本地路径
const [liveVideoPath, setLiveVideoPath] = useState<string>('') // Live Photo 视频路径
const [isDecrypting, setIsDecrypting] = useState(false) // 解密状态
const { url, thumb, livePhoto } = media
const isLive = !!livePhoto
const targetUrl = thumb || url
const targetUrl = thumb || url // 默认显示缩略图
// 判断是否为视频
const isVideo = url && (url.includes('snsvideodownload') || url.includes('.mp4') || url.includes('video')) && !url.includes('vweixinthumb')
useEffect(() => {
let cancelled = false
setError(false)
setResolvedSrc('')
setThumbSrc('')
setVideoPath('')
setLiveVideoPath('')
setIsDecrypting(false)
const extractFirstFrame = (videoUrl: string) => {
const video = document.createElement('video')
video.crossOrigin = 'anonymous'
video.style.display = 'none'
video.muted = true
video.src = videoUrl
video.currentTime = 0.1
const onLoadedData = () => {
if (cancelled) return cleanup()
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
if (!cancelled) {
setThumbSrc(dataUrl)
setIsDecrypting(false)
}
} else {
if (!cancelled) setIsDecrypting(false)
}
} catch (e) {
console.warn('Frame extraction error', e)
if (!cancelled) setIsDecrypting(false)
} finally {
cleanup()
}
}
const onError = () => {
if (!cancelled) {
setIsDecrypting(false)
setThumbSrc(targetUrl) // Fallback
}
cleanup()
}
const cleanup = () => {
video.removeEventListener('seeked', onLoadedData)
video.removeEventListener('error', onError)
video.remove()
}
video.addEventListener('seeked', onLoadedData)
video.addEventListener('error', onError)
video.load()
}
const run = async () => {
try {
const result = await window.electronAPI.sns.proxyImage({
url: targetUrl,
key: media.key
})
if (cancelled) return
if (result.success && result.dataUrl) {
setResolvedSrc(result.dataUrl)
if (isVideo) {
setIsDecrypting(true)
const videoResult = await window.electronAPI.sns.proxyImage({
url: url,
key: media.key
})
if (cancelled) return
if (videoResult.success && videoResult.videoPath) {
const localUrl = videoResult.videoPath.startsWith('file:')
? videoResult.videoPath
: `file://${videoResult.videoPath.replace(/\\/g, '/')}`
setVideoPath(localUrl)
extractFirstFrame(localUrl)
} else {
console.warn('[MediaItem] Video decryption failed:', url, videoResult.error)
setIsDecrypting(false)
setError(true)
}
} else {
setResolvedSrc(targetUrl)
const result = await window.electronAPI.sns.proxyImage({
url: targetUrl,
key: media.key
})
if (cancelled) return
if (result.success) {
if (result.dataUrl) {
setThumbSrc(result.dataUrl)
} else if (result.videoPath) {
const localUrl = result.videoPath.startsWith('file:')
? result.videoPath
: `file://${result.videoPath.replace(/\\/g, '/')}`
setThumbSrc(localUrl)
}
} else {
console.warn('[MediaItem] Image proxy failed:', targetUrl, result.error)
setThumbSrc(targetUrl)
}
if (isLive && livePhoto && livePhoto.url) {
window.electronAPI.sns.proxyImage({
url: livePhoto.url,
key: livePhoto.key || media.key
}).then((res: any) => {
if (cancelled) return
if (res.success && res.videoPath) {
const localUrl = res.videoPath.startsWith('file:')
? res.videoPath
: `file://${res.videoPath.replace(/\\/g, '/')}`
setLiveVideoPath(localUrl)
console.log('[MediaItem] Live video ready:', localUrl)
} else {
console.warn('[MediaItem] Live video failed:', res.error)
}
}).catch((e: any) => console.error('[MediaItem] Live video err:', e))
}
}
} catch (err) {
if (!cancelled) {
console.error('[MediaItem] run error:', err)
setError(true)
setIsDecrypting(false)
}
} catch {
if (!cancelled) setResolvedSrc(targetUrl)
}
}
run()
return () => { cancelled = true }
}, [targetUrl, media.key])
}, [targetUrl, url, media.key, isVideo, isLive, livePhoto])
const handleDownload = (e: React.MouseEvent) => {
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation()
// TODO: call backend download service
try {
const result = await window.electronAPI.sns.downloadImage({
url: url || targetUrl, // Use original url if available
key: media.key
})
if (!result.success && result.error !== '用户已取消') {
alert(`下载失败: ${result.error}`)
}
} catch (error) {
console.error('Download failed:', error)
alert('下载过程中发生错误')
}
}
const displaySrc = resolvedSrc || targetUrl
const previewSrc = resolvedSrc || url || targetUrl
// 点击时:如果是视频,应该传视频地址给 Preview
// ImagePreview 目前可能只支持图片。需要检查 ImagePreview 是否支持视频。
// 假设 ImagePreview 暂不支持视频播放,我们可以在这里直接点开播放?
// 或者,传视频 URL 给 onPreview让父组件决定/ImagePreview 决定。
// 通常做法:传给 ImagePreviewImagePreview 识别 mp4 后播放。
// 显示用的图片:始终显示缩略图
const displaySrc = thumbSrc || targetUrl
// 预览用的地址:如果是视频,优先使用本地路径
const previewSrc = isVideo ? (videoPath || url) : (thumbSrc || url || targetUrl)
// 点击处理:解密中禁止点击
const handleClick = () => {
if (isVideo && isDecrypting) return
onPreview(previewSrc, isVideo, liveVideoPath)
}
return (
<div className={`media-item ${error ? 'error' : ''}`} onClick={() => onPreview(previewSrc)}>
<img
src={displaySrc}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setError(true)}
/>
{isLive && (
<div className={`media-item ${error ? 'error' : ''} ${isVideo && isDecrypting ? 'decrypting' : ''}`} onClick={handleClick}>
{isVideo && isDecrypting ? (
<div className="video-loading-overlay" style={{
position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', color: '#fff',
zIndex: 2, backdropFilter: 'blur(4px)'
}}>
<RefreshCw size={24} className="spin-icon" style={{ marginBottom: 8 }} />
<span style={{ fontSize: 12 }}>...</span>
</div>
) : (
<img
src={displaySrc}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setError(true)}
/>
)}
{isVideo && !isDecrypting && (
<div className="video-badge-container">
<div className="video-badge">
<Play size={16} className="play-icon" />
</div>
</div>
)}
{isLive && !isVideo && (
<div className="live-badge">
<LivePhotoIcon size={16} className="live-icon" />
</div>
@@ -120,7 +281,7 @@ export default function SnsPage() {
const [contactsLoading, setContactsLoading] = useState(false)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [previewImage, setPreviewImage] = useState<{ src: string, isVideo?: boolean, liveVideoPath?: string } | null>(null)
const [debugPost, setDebugPost] = useState<SnsPost | null>(null)
const postsContainerRef = useRef<HTMLDivElement>(null)
@@ -169,7 +330,7 @@ export default function SnsPage() {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
const topTs = currentPosts[0].createTime
const result = await window.electronAPI.sns.getTimeline(
limit,
@@ -301,10 +462,10 @@ export default function SnsPage() {
const checkSchema = async () => {
try {
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
if (schema.success && schema.rows) {
const columns = schema.rows.map((r: any) => r.name);
}
} catch (e) {
console.error('[SnsPage] Failed to check schema:', e);
@@ -355,7 +516,7 @@ export default function SnsPage() {
// deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
loadPosts({ direction: 'newer' })
}
}
@@ -432,10 +593,6 @@ export default function SnsPage() {
</div>
<div className="sns-content-wrapper">
<div className="sns-notice-banner">
<AlertTriangle size={16} />
<span></span>
</div>
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="posts-list">
{loadingNewer && (
@@ -483,15 +640,10 @@ export default function SnsPage() {
<div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? (
<div className="post-video-placeholder">
<Play size={20} />
<span></span>
</div>
) : post.media.length > 0 && (
{post.media.length > 0 && (
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
{post.media.map((m, idx) => (
<MediaItem key={idx} media={m} onPreview={(src) => setPreviewImage(src)} />
<MediaItem key={idx} media={m} onPreview={(src, isVideo, liveVideoPath) => setPreviewImage({ src, isVideo, liveVideoPath })} />
))}
</div>
)}
@@ -664,7 +816,12 @@ export default function SnsPage() {
</aside>
</div>
{previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
<ImagePreview
src={previewImage.src}
isVideo={previewImage.isVideo}
liveVideoPath={previewImage.liveVideoPath}
onClose={() => setPreviewImage(null)}
/>
)}
<JumpToDateDialog
isOpen={showJumpDialog}