实况播放更加丝滑

This commit is contained in:
xuncha
2026-02-25 13:54:06 +08:00
parent b547ac1aed
commit fbcf7d2fc3
2 changed files with 161 additions and 54 deletions

View File

@@ -46,6 +46,18 @@
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
} }
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn {
&.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
} }
.scale-text { .scale-text {
@@ -78,14 +90,40 @@
cursor: grabbing; cursor: grabbing;
} }
img, video { .media-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
will-change: transform;
}
img,
video {
display: block;
max-width: none; max-width: none;
max-height: none; max-height: none;
object-fit: contain; object-fit: contain;
will-change: transform;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
pointer-events: auto; pointer-events: auto;
} }
.live-video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
opacity: 0;
will-change: opacity;
transition: opacity 0.3s ease-in-out;
}
.live-video.visible {
opacity: 1;
}
} }
} }

View File

@@ -1,4 +1,3 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
@@ -9,13 +8,17 @@ export default function ImageWindow() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath') const imagePath = searchParams.get('imagePath')
const liveVideoPath = searchParams.get('liveVideoPath') const liveVideoPath = searchParams.get('liveVideoPath')
const [showLive, setShowLive] = useState(false) const hasLiveVideo = !!liveVideoPath
const [isPlayingLive, setIsPlayingLive] = useState(false)
const [isVideoVisible, setIsVideoVisible] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const liveCleanupTimerRef = useRef<number | null>(null)
const [scale, setScale] = useState(1) const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0) const [rotation, setRotation] = useState(0)
const [position, setPosition] = useState({ x: 0, y: 0 }) const [position, setPosition] = useState({ x: 0, y: 0 })
const [initialScale, setInitialScale] = useState(1) const [initialScale, setInitialScale] = useState(1)
const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 })
const viewportRef = useRef<HTMLDivElement>(null) const viewportRef = useRef<HTMLDivElement>(null)
// 使用 ref 存储拖动状态,避免闭包问题 // 使用 ref 存储拖动状态,避免闭包问题
@@ -27,6 +30,44 @@ export default function ImageWindow() {
startPosY: 0 startPosY: 0
}) })
const clearLiveCleanupTimer = useCallback(() => {
if (liveCleanupTimerRef.current !== null) {
window.clearTimeout(liveCleanupTimerRef.current)
liveCleanupTimerRef.current = null
}
}, [])
const stopLivePlayback = useCallback((immediate = false) => {
clearLiveCleanupTimer()
setIsVideoVisible(false)
if (immediate) {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
return
}
liveCleanupTimerRef.current = window.setTimeout(() => {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
liveCleanupTimerRef.current = null
}, 300)
}, [clearLiveCleanupTimer])
const handlePlayLiveVideo = useCallback(() => {
if (!liveVideoPath || isPlayingLive) return
clearLiveCleanupTimer()
setIsPlayingLive(true)
setIsVideoVisible(false)
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10)) const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1)) const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
const handleRotate = () => setRotation(prev => (prev + 90) % 360) const handleRotate = () => setRotation(prev => (prev + 90) % 360)
@@ -44,7 +85,6 @@ export default function ImageWindow() {
const img = e.currentTarget const img = e.currentTarget
const naturalWidth = img.naturalWidth const naturalWidth = img.naturalWidth
const naturalHeight = img.naturalHeight const naturalHeight = img.naturalHeight
setImgNatural({ w: naturalWidth, h: naturalHeight })
if (viewportRef.current) { if (viewportRef.current) {
const viewportWidth = viewportRef.current.clientWidth * 0.9 const viewportWidth = viewportRef.current.clientWidth * 0.9
@@ -57,6 +97,29 @@ export default function ImageWindow() {
} }
}, []) }, [])
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
useEffect(() => {
if (!isPlayingLive || !videoRef.current) return
const timer = window.setTimeout(() => {
const video = videoRef.current
if (!video || !isPlayingLive || !video.paused) return
video.currentTime = 0
void video.play().catch(() => {
stopLivePlayback(true)
})
}, 0)
return () => window.clearTimeout(timer)
}, [isPlayingLive, stopLivePlayback])
useEffect(() => {
return () => {
clearLiveCleanupTimer()
}
}, [clearLiveCleanupTimer])
// 使用原生事件监听器处理拖动 // 使用原生事件监听器处理拖动
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
@@ -112,15 +175,25 @@ export default function ImageWindow() {
// 快捷键支持 // 快捷键支持
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close() if (e.key === 'Escape') {
if (isPlayingLive) {
stopLivePlayback(true)
return
}
window.electronAPI.window.close()
}
if (e.key === '=' || e.key === '+') handleZoomIn() if (e.key === '=' || e.key === '+') handleZoomIn()
if (e.key === '-') handleZoomOut() if (e.key === '-') handleZoomOut()
if (e.key === 'r' || e.key === 'R') handleRotate() if (e.key === 'r' || e.key === 'R') handleRotate()
if (e.key === '0') handleReset() if (e.key === '0') handleReset()
if (e.key === ' ' && hasLiveVideo) {
e.preventDefault()
handlePlayLiveVideo()
}
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset]) }, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
if (!imagePath) { if (!imagePath) {
return ( return (
@@ -137,22 +210,19 @@ export default function ImageWindow() {
<div className="title-bar"> <div className="title-bar">
<div className="window-drag-area"></div> <div className="window-drag-area"></div>
<div className="title-bar-controls"> <div className="title-bar-controls">
{liveVideoPath && ( {hasLiveVideo && (
<>
<button <button
onClick={() => { onClick={handlePlayLiveVideo}
const next = !showLive title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
setShowLive(next) className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
if (next && videoRef.current) { disabled={isPlayingLive}
videoRef.current.currentTime = 0
videoRef.current.play()
}
}}
title={showLive ? '显示照片' : '播放实况'}
className={showLive ? 'active' : ''}
> >
<LivePhotoIcon size={16} /> <LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span> <span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button> </button>
<div className="divider"></div>
</>
)} )}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button> <button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span> <span className="scale-text">{Math.round(displayScale * 100)}%</span>
@@ -170,32 +240,31 @@ export default function ImageWindow() {
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
{liveVideoPath && ( <div
<video className="media-wrapper"
ref={videoRef}
src={liveVideoPath}
width={imgNatural.w || undefined}
height={imgNatural.h || undefined}
style={{ style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`, transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
position: showLive ? 'relative' : 'absolute',
opacity: showLive ? 1 : 0,
pointerEvents: showLive ? 'auto' : 'none'
}} }}
onEnded={() => setShowLive(false)} >
/>
)}
<img <img
src={imagePath} src={imagePath}
alt="Preview" alt="Preview"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`,
opacity: showLive ? 0 : 1,
position: showLive ? 'absolute' : 'relative'
}}
onLoad={handleImageLoad} onLoad={handleImageLoad}
draggable={false} draggable={false}
/> />
{hasLiveVideo && isPlayingLive && (
<video
ref={videoRef}
src={liveVideoPath || ''}
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
autoPlay
playsInline
preload="auto"
onPlaying={() => setIsVideoVisible(true)}
onEnded={() => stopLivePlayback(false)}
/>
)}
</div>
</div> </div>
</div> </div>
) )