feat: enhance Live Photo functionality and improve user experience

- Added support for long press to play Live Photos on mobile devices.
- Implemented a ref for LivePhoto component to control play and stop actions.
- Updated localization files to include new strings for Live Photo playback status.
- Enhanced CSS styles to prevent text selection and improve layout consistency.
- Refactored ExifPanel and ProgressiveImage components for better integration with Live Photo features.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-06-28 14:00:12 +08:00
parent 6d430faa9a
commit 5b512d60a7
12 changed files with 170 additions and 203 deletions

View File

@@ -82,7 +82,7 @@ export const ExifPanel: FC<{
<ScrollArea
rootClassName="flex-1 min-h-0 overflow-auto lg:overflow-hidden"
viewportClassName="px-4 pb-4"
viewportClassName="px-4 pb-4 [&_*]:select-text"
>
<div className={`space-y-${isMobile ? '3' : '4'}`}>
{/* 基本信息和标签 - 合并到一个 section */}

View File

@@ -1,5 +1,11 @@
import { m, useAnimationControls } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AnimatePresence, m, useAnimationControls } from 'motion/react'
import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { clsxm } from '~/lib/cn'
@@ -19,30 +25,36 @@ interface LivePhotoProps {
isCurrentImage: boolean
/** 自定义样式类名 */
className?: string
onPlayingChange?: (isPlaying: boolean) => void
}
export interface LivePhotoHandle {
play: () => void
stop: () => void
getIsVideoLoaded: () => boolean
}
export const LivePhoto = ({
ref,
videoUrl,
imageLoaderManager,
loadingIndicatorRef,
isCurrentImage,
className,
}: LivePhotoProps) => {
onPlayingChange,
}: LivePhotoProps & { ref?: React.RefObject<LivePhotoHandle | null> }) => {
const { t } = useTranslation()
// Live Photo 相关状态
const [isPlayingLivePhoto, setIsPlayingLivePhoto] = useState(false)
const [livePhotoVideoLoaded, setLivePhotoVideoLoaded] = useState(false)
const [isConvertingVideo, setIsConvertingVideo] = useState(false)
const [conversionMethod, setConversionMethod] = useState<string>('')
const videoRef = useRef<HTMLVideoElement>(null)
const videoAnimateController = useAnimationControls()
// Live Photo hover 相关
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLongPressing, setIsLongPressing] = useState(false)
useEffect(() => {
onPlayingChange?.(isPlayingLivePhoto)
}, [isPlayingLivePhoto, onPlayingChange])
useEffect(() => {
if (
@@ -53,9 +65,7 @@ export const LivePhoto = ({
) {
return
}
setIsConvertingVideo(true)
const processVideo = async () => {
try {
const videoResult = await imageLoaderManager.processLivePhotoVideo(
@@ -67,11 +77,9 @@ export const LivePhoto = ({
},
},
)
if (videoResult.conversionMethod) {
setConversionMethod(videoResult.conversionMethod)
}
setLivePhotoVideoLoaded(true)
} catch (videoError) {
console.error('Failed to process Live Photo video:', videoError)
@@ -79,7 +87,6 @@ export const LivePhoto = ({
setIsConvertingVideo(false)
}
}
processVideo()
}, [
isCurrentImage,
@@ -90,64 +97,28 @@ export const LivePhoto = ({
loadingIndicatorRef,
])
// 清理函数
useEffect(() => {
return () => {
// Clean up timers
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}
}, [])
// 重置状态(当不是当前图片时)
useEffect(() => {
if (!isCurrentImage) {
setIsPlayingLivePhoto(false)
setLivePhotoVideoLoaded(false)
setIsConvertingVideo(false)
setConversionMethod('')
setIsLongPressing(false)
// Clean up timers
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
}
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
// Reset video animation
videoAnimateController.set({ opacity: 0 })
}
}, [isCurrentImage, videoAnimateController])
// Live Photo hover 处理
const handleBadgeMouseEnter = useCallback(() => {
const play = useCallback(async () => {
if (!livePhotoVideoLoaded || isPlayingLivePhoto || isConvertingVideo) return
hoverTimerRef.current = setTimeout(async () => {
setIsPlayingLivePhoto(true)
// 开始淡入动画
await videoAnimateController.start({
opacity: 1,
transition: { duration: 0.15, ease: 'easeOut' },
})
const video = videoRef.current
if (video) {
video.currentTime = 0
video.play()
}
}, 200) // 200ms hover 延迟
setIsPlayingLivePhoto(true)
await videoAnimateController.start({
opacity: 1,
transition: { duration: 0.15, ease: 'easeOut' },
})
const video = videoRef.current
if (video) {
video.currentTime = 0
video.play()
}
}, [
livePhotoVideoLoaded,
isPlayingLivePhoto,
@@ -155,142 +126,69 @@ export const LivePhoto = ({
videoAnimateController,
])
const handleBadgeMouseLeave = useCallback(async () => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = null
const stop = useCallback(async () => {
if (!isPlayingLivePhoto) return
const video = videoRef.current
if (video) {
video.pause()
video.currentTime = 0
}
if (isPlayingLivePhoto) {
const video = videoRef.current
if (video) {
video.pause()
video.currentTime = 0
}
// 开始淡出动画
await videoAnimateController.start({
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
})
setIsPlayingLivePhoto(false)
}
}, [isPlayingLivePhoto, videoAnimateController])
// 视频播放结束处理
const handleVideoEnded = useCallback(async () => {
// 播放结束时淡出
await videoAnimateController.start({
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
})
setIsPlayingLivePhoto(false)
}, [videoAnimateController])
}, [isPlayingLivePhoto, videoAnimateController])
// Live Photo 长按处理(移动端)
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (
!livePhotoVideoLoaded ||
isPlayingLivePhoto ||
isConvertingVideo ||
e.touches.length > 1 // 多指触摸不触发长按
)
return
useImperativeHandle(ref, () => ({
play,
stop,
getIsVideoLoaded: () => livePhotoVideoLoaded,
}))
longPressTimerRef.current = setTimeout(async () => {
setIsLongPressing(true)
setIsPlayingLivePhoto(true)
const handleVideoEnded = useCallback(() => {
stop()
}, [stop])
// 开始淡入动画
await videoAnimateController.start({
opacity: 1,
transition: { duration: 0.15, ease: 'easeOut' },
})
// Desktop hover logic
const handleBadgeMouseEnter = useCallback(() => {
if (isMobileDevice) return
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
hoverTimerRef.current = setTimeout(play, 200)
}, [play])
const video = videoRef.current
if (video) {
video.currentTime = 0
video.play()
}
}, 500) // 500ms 长按延迟
},
[
livePhotoVideoLoaded,
isPlayingLivePhoto,
isConvertingVideo,
videoAnimateController,
],
)
const handleTouchEnd = useCallback(async () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
if (isLongPressing && isPlayingLivePhoto) {
setIsLongPressing(false)
const video = videoRef.current
if (video) {
video.pause()
video.currentTime = 0
}
// 开始淡出动画
await videoAnimateController.start({
opacity: 0,
transition: { duration: 0.2, ease: 'easeIn' },
})
setIsPlayingLivePhoto(false)
}
}, [isLongPressing, isPlayingLivePhoto, videoAnimateController])
const handleTouchMove = useCallback(() => {
// 触摸移动时取消长按
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}, [])
const handleBadgeMouseLeave = useCallback(() => {
if (isMobileDevice) return
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current)
stop()
}, [stop])
return (
<>
{/* Live Photo 视频 */}
<m.video
ref={videoRef}
className={clsxm(
'absolute inset-0 z-10 h-full w-full object-contain',
!isPlayingLivePhoto && 'hidden',
'pointer-events-none absolute inset-0 z-10 h-full w-full object-contain',
className,
)}
style={{
opacity: isPlayingLivePhoto ? 1 : 0,
transition: 'opacity 0.2s ease-in-out',
}}
muted
playsInline
onEnded={handleVideoEnded}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
initial={{ opacity: 0 }}
animate={videoAnimateController}
/>
{/* Live Photo 标识 */}
<div
className={clsxm(
'absolute z-50 flex items-center space-x-1 rounded-xl bg-black/50 px-1 py-1 text-xs text-white cursor-pointer transition-all duration-200 hover:bg-black/70',
'absolute z-20 flex items-center space-x-1 rounded-xl bg-black/50 px-1 py-1 text-xs text-white transition-all duration-200',
!isMobileDevice && 'cursor-pointer hover:bg-black/70',
import.meta.env.DEV ? 'top-16 right-4' : 'top-12 lg:top-4 left-4',
)}
onMouseEnter={handleBadgeMouseEnter}
onMouseLeave={handleBadgeMouseLeave}
title={
isMobileDevice
? t('photo.live.tooltip.mobile.main')
: t('photo.live.tooltip.desktop.main')
}
>
{isConvertingVideo ? (
<div className="flex items-center gap-1 px-1">
@@ -307,13 +205,29 @@ export const LivePhoto = ({
</>
)}
</div>
{/* 操作提示 */}
<div className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded bg-black/50 px-2 py-1 text-xs text-white opacity-0 duration-200 group-hover:opacity-50">
<AnimatePresence>
{isPlayingLivePhoto && (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2"
>
<div className="flex items-center gap-2 rounded bg-black/50 px-2 py-1 text-xs text-white">
<i className="i-mingcute-live-photo-line" />
<span>{t('photo.live.playing')}</span>
</div>
</m.div>
)}
</AnimatePresence>
<div
className={clsxm(
'pointer-events-none absolute bottom-4 left-1/2 z-20 -translate-x-1/2 rounded bg-black/50 px-2 py-1 text-xs text-white opacity-0 duration-200 group-hover:opacity-50',
isPlayingLivePhoto && 'opacity-0!',
)}
>
{isConvertingVideo
? t('photo.live.converting.detail', {
method: 'transmux',
})
? t('photo.live.converting.detail', { method: 'transmux' })
: isMobileDevice
? t('photo.live.tooltip.mobile.zoom')
: t('photo.live.tooltip.desktop.zoom')}
@@ -321,3 +235,5 @@ export const LivePhoto = ({
</>
)
}
LivePhoto.displayName = 'LivePhoto'

View File

@@ -16,10 +16,12 @@ import {
useShowContextMenu,
} from '~/atoms/context-menu'
import { clsxm } from '~/lib/cn'
import { isMobileDevice } from '~/lib/device-viewport'
import { canUseWebGL } from '~/lib/feature'
import { ImageLoaderManager } from '~/lib/image-loader-manager'
import { SlidingNumber } from '../number/SlidingNumber'
import type { LivePhotoHandle } from './LivePhoto'
import { LivePhoto } from './LivePhoto'
import type { LoadingIndicatorRef } from './LoadingIndicator'
import { LoadingIndicator } from './LoadingIndicator'
@@ -88,6 +90,9 @@ export const ProgressiveImage = ({
const loadingIndicatorRef = useRef<LoadingIndicatorRef>(null)
const imageLoaderManagerRef = useRef<ImageLoaderManager | null>(null)
const livePhotoRef = useRef<LivePhotoHandle>(null)
const longPressTimerRef = useRef<NodeJS.Timeout | null>(null)
const [isLivePhotoPlaying, setIsLivePhotoPlaying] = useState(false)
useEffect(() => {
if (highResLoaded || error || !isCurrentImage) return
@@ -167,6 +172,29 @@ export const ProgressiveImage = ({
[onZoomChange],
)
const handleLongPressStart = useCallback(() => {
if (!isMobileDevice) return
const playVideo = () => livePhotoRef.current?.play()
if (
!isLivePhoto ||
!livePhotoRef.current?.getIsVideoLoaded() ||
isLivePhotoPlaying
) {
return
}
longPressTimerRef.current = setTimeout(playVideo, 200)
}, [isLivePhoto, isLivePhotoPlaying])
const handleLongPressEnd = useCallback(() => {
if (!isMobileDevice) return
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
}
if (isLivePhotoPlaying) {
livePhotoRef.current?.stop()
}
}, [isLivePhotoPlaying])
const handleWebGLLoadingStateChange = useCallback(
(
isLoading: boolean,
@@ -216,7 +244,14 @@ export const ProgressiveImage = ({
}
return (
<div className={clsxm('relative overflow-hidden', className)}>
<div
className={clsxm('relative overflow-hidden', className)}
onMouseDown={handleLongPressStart}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={handleLongPressStart}
onTouchEnd={handleLongPressEnd}
>
{/* 缩略图 */}
{thumbnailSrc && !isHighResImageRendered && (
<img
@@ -346,10 +381,12 @@ export const ProgressiveImage = ({
isCurrentImage &&
imageLoaderManagerRef.current && (
<LivePhoto
ref={livePhotoRef}
videoUrl={livePhotoVideoUrl}
imageLoaderManager={imageLoaderManagerRef.current}
loadingIndicatorRef={loadingIndicatorRef}
isCurrentImage={isCurrentImage}
onPlayingChange={setIsLivePhotoPlaying}
/>
)}

View File

@@ -156,7 +156,9 @@ const categories = {
'GPS Position',
'GPS Speed',
'GPS Speed Ref',
'GPS Map Datum',
'GPS Time Stamp',
'GPS Date Stamp',
'GPS Date/Time',
],
imageProperties: [
'Image Width',
@@ -377,7 +379,7 @@ export const RawExifViewer: React.FC<RawExifViewerProps> = ({
<ScrollArea
rootClassName="h-0 grow flex-1 -mb-6 -mx-6"
viewportClassName="px-7 pb-6 pt-4"
viewportClassName="px-7 pb-6 pt-4 [&_*]:select-text"
flex
>
<div className="min-w-0 space-y-6">

View File

@@ -8,6 +8,6 @@ export const isMobileDevice = (() => {
navigator.userAgent,
) ||
// 现代检测方式:支持触摸且屏幕较小
('ontouchstart' in window && window.screen.width < 1024)
'ontouchstart' in window
)
})()

View File

@@ -8,6 +8,12 @@ body {
@apply grow h-0 shrink-0 overflow-auto relative;
}
@layer utilities {
* {
user-select: none;
}
}
@media screen and (min-width: 1024px) {
::-webkit-scrollbar-thumb {
background-color: transparent;

View File

@@ -235,6 +235,7 @@
"photo.live.badge": "Live",
"photo.live.converting.detail": "Converting video format using {{method}}...",
"photo.live.converting.video": "Converting Live Photo video",
"photo.live.playing": "Playing Live Photo",
"photo.live.tooltip.desktop.main": "Hover to play Live Photo",
"photo.live.tooltip.desktop.zoom": "Hover to play Live Photo / Double-click to zoom",
"photo.live.tooltip.mobile.main": "Long press to play Live Photo",

View File

@@ -231,11 +231,12 @@
"photo.copying": "画像をコピーしています...",
"photo.download": "画像をダウンロード",
"photo.error.loading": "画像の読み込みに失敗しました",
"photo.live.badge": "ライブ",
"photo.live.badge": "Live",
"photo.live.converting.detail": "{{method}}を使用してビデオ形式を変換しています...",
"photo.live.converting.video": "ライブフォトビデオを変換しています",
"photo.live.tooltip.desktop.main": "ホバーしてライブフォトを再生",
"photo.live.tooltip.desktop.zoom": "「ライブ」バッジをホバーして再生/ダブルクリックしてズーム",
"photo.live.converting.video": "Live Photoのビデオを変換",
"photo.live.playing": "ライブ写真を再生",
"photo.live.tooltip.desktop.main": "ホバーしてLive Photoを再生",
"photo.live.tooltip.desktop.zoom": "ホバーしてLive Photoを再生 / ダブルクリックしてズーム",
"photo.live.tooltip.mobile.main": "長押ししてライブフォトを再生",
"photo.live.tooltip.mobile.zoom": "長押ししてライブフォトを再生/ダブルタップしてズーム",
"photo.share.actions": "アクション",

View File

@@ -230,12 +230,13 @@
"photo.copy.success": "이미지를 클립보드에 복사했습니다.",
"photo.copying": "이미지 복사 중...",
"photo.download": "이미지 다운로드",
"photo.error.loading": "이미지 로딩 실패",
"photo.error.loading": "이미지를 불러오지 못했습니다",
"photo.live.badge": "라이브",
"photo.live.converting.detail": "{{method}}을 (를) 사용하여 비디오 형식 변환 중...",
"photo.live.converting.detail": "{{method}} 사용하여 비디오 형식 변환하는 중...",
"photo.live.converting.video": "라이브 포토 비디오 변환 중",
"photo.live.tooltip.desktop.main": "마우스를 올려 라이브 포토 재생",
"photo.live.tooltip.desktop.zoom": "‘라이브’배지에 마우스를 올려 재생/더블 클릭하여 확대",
"photo.live.playing": "라이브 포토 재생",
"photo.live.tooltip.desktop.main": "호버하여 라이브 포토 재생",
"photo.live.tooltip.desktop.zoom": "호버하여 라이브 포토 재생 / 더블 클릭하여 확대",
"photo.live.tooltip.mobile.main": "길게 눌러 라이브 포토 재생",
"photo.live.tooltip.mobile.zoom": "길게 눌러 라이브 포토 재생/더블 탭하여 확대",
"photo.share.actions": "작업",

View File

@@ -231,12 +231,13 @@
"photo.copy.success": "图像已复制到剪贴板",
"photo.copying": "正在复制图像...",
"photo.download": "下载图像",
"photo.error.loading": "图加载失败",
"photo.error.loading": "图加载失败",
"photo.live.badge": "实况",
"photo.live.converting.detail": "正在使用 {{method}} 转换视频格式...",
"photo.live.converting.video": "正在转换实况照片视频",
"photo.live.playing": "正在播放实况照片",
"photo.live.tooltip.desktop.main": "悬停以播放实况照片",
"photo.live.tooltip.desktop.zoom": "悬停\"实况\"徽章以播放/双击缩放",
"photo.live.tooltip.desktop.zoom": "悬停播放实况照片 / 双击缩放",
"photo.live.tooltip.mobile.main": "长按以播放实况照片",
"photo.live.tooltip.mobile.zoom": "长按以播放实况照片/双击以缩放",
"photo.reaction.success": "点赞成功",

View File

@@ -230,14 +230,15 @@
"photo.copy.success": "圖像已複製到剪貼簿",
"photo.copying": "正在複製圖像...",
"photo.download": "下載圖像",
"photo.error.loading": "圖載入失敗",
"photo.live.badge": "況",
"photo.error.loading": "圖載入失敗",
"photo.live.badge": "況",
"photo.live.converting.detail": "正在使用 {{method}} 轉換影片格式...",
"photo.live.converting.video": "正在轉換況照片影片",
"photo.live.tooltip.desktop.main": "懸停以播放況照片",
"photo.live.tooltip.desktop.zoom": "懸停「實況」徽章以播放/雙擊以縮放",
"photo.live.tooltip.mobile.main": "長按以播放況照片",
"photo.live.tooltip.mobile.zoom": "長按以播放況照片/雙擊以縮放",
"photo.live.converting.video": "正在轉換況照片影片",
"photo.live.playing": "正在播放況照片",
"photo.live.tooltip.desktop.main": "懸停以播放原況照片",
"photo.live.tooltip.desktop.zoom": "懸停播放況照片 / 輕按兩下縮放",
"photo.live.tooltip.mobile.main": "長按以播放況照片",
"photo.live.tooltip.mobile.zoom": "長按以播放原況照片/雙擊以縮放",
"photo.share.actions": "操作",
"photo.share.copy.failed": "複製失敗",
"photo.share.copy.link": "複製連結",

View File

@@ -230,14 +230,15 @@
"photo.copy.success": "圖像已複製到剪貼簿",
"photo.copying": "正在複製圖像...",
"photo.download": "下載圖像",
"photo.error.loading": "圖載入失敗",
"photo.live.badge": "況",
"photo.error.loading": "圖載入失敗",
"photo.live.badge": "況",
"photo.live.converting.detail": "正在使用 {{method}} 轉換影片格式...",
"photo.live.converting.video": "正在轉換況照片影片",
"photo.live.tooltip.desktop.main": "懸停以播放況照片",
"photo.live.tooltip.desktop.zoom": "懸停「實況」徽章以播放/雙擊以縮放",
"photo.live.tooltip.mobile.main": "長按以播放況照片",
"photo.live.tooltip.mobile.zoom": "長按以播放況照片/雙擊以縮放",
"photo.live.converting.video": "正在轉換況照片影片",
"photo.live.playing": "正在播放況照片",
"photo.live.tooltip.desktop.main": "懸停以播放原況照片",
"photo.live.tooltip.desktop.zoom": "懸停播放況照片 / 輕按兩下縮放",
"photo.live.tooltip.mobile.main": "長按以播放況照片",
"photo.live.tooltip.mobile.zoom": "長按以播放原況照片/雙擊以縮放",
"photo.share.actions": "操作",
"photo.share.copy.failed": "複製失敗",
"photo.share.copy.link": "複製連結",