mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
实况播放更加丝滑
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
<>
|
||||||
onClick={() => {
|
<button
|
||||||
const next = !showLive
|
onClick={handlePlayLiveVideo}
|
||||||
setShowLive(next)
|
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
|
||||||
if (next && videoRef.current) {
|
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
|
||||||
videoRef.current.currentTime = 0
|
disabled={isPlayingLive}
|
||||||
videoRef.current.play()
|
>
|
||||||
}
|
<LivePhotoIcon size={16} />
|
||||||
}}
|
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
||||||
title={showLive ? '显示照片' : '播放实况'}
|
</button>
|
||||||
className={showLive ? 'active' : ''}
|
<div className="divider"></div>
|
||||||
>
|
</>
|
||||||
<LivePhotoIcon size={16} />
|
|
||||||
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<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={{
|
|
||||||
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
|
|
||||||
src={imagePath}
|
|
||||||
alt="Preview"
|
|
||||||
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)`
|
||||||
opacity: showLive ? 0 : 1,
|
|
||||||
position: showLive ? 'absolute' : 'relative'
|
|
||||||
}}
|
}}
|
||||||
onLoad={handleImageLoad}
|
>
|
||||||
draggable={false}
|
<img
|
||||||
/>
|
src={imagePath}
|
||||||
|
alt="Preview"
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user