diff --git a/src-tauri/src/commands/file_utils.rs b/src-tauri/src/commands/file_utils.rs index 64ad135..556388c 100644 --- a/src-tauri/src/commands/file_utils.rs +++ b/src-tauri/src/commands/file_utils.rs @@ -40,3 +40,57 @@ pub async fn read_image_as_data_url(file_path: String) -> Result Err(e) => Err(format!("Failed to read file: {}", e)), } } + +/// 读取视频文件并返回base64编码的数据URL +#[command] +pub async fn read_video_as_data_url(file_path: String) -> Result { + let path = Path::new(&file_path); + + // 检查文件是否存在 + if !path.exists() { + return Err("File does not exist".to_string()); + } + + // 检查是否是视频文件 + let extension = path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_lowercase()) + .unwrap_or_default(); + + let mime_type = match extension.as_str() { + "mp4" => "video/mp4", + "avi" => "video/x-msvideo", + "mov" => "video/quicktime", + "mkv" => "video/x-matroska", + "wmv" => "video/x-ms-wmv", + "flv" => "video/x-flv", + "webm" => "video/webm", + "m4v" => "video/x-m4v", + _ => return Err("Unsupported video format".to_string()), + }; + + // 读取文件内容 + match fs::read(&file_path) { + Ok(file_data) => { + // 编码为base64 + let base64_data = general_purpose::STANDARD.encode(&file_data); + // 返回data URL格式 + Ok(format!("data:{};base64,{}", mime_type, base64_data)) + } + Err(e) => Err(format!("Failed to read file: {}", e)), + } +} + +/// 获取视频文件的blob URL(用于大文件) +#[command] +pub async fn get_video_blob_url(file_path: String) -> Result { + let path = Path::new(&file_path); + + // 检查文件是否存在 + if !path.exists() { + return Err("File does not exist".to_string()); + } + + // 对于视频文件,我们返回文件路径,让前端使用convertFileSrc + Ok(file_path) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b89306..7aa52a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,7 +82,9 @@ pub fn run() { commands::media::search_segments, commands::media::delete_segment, commands::media::delete_original_video, - commands::file_utils::read_image_as_data_url + commands::file_utils::read_image_as_data_url, + commands::file_utils::read_video_as_data_url, + commands::file_utils::get_video_blob_url ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..bf326a5 --- /dev/null +++ b/src/components/VideoPlayer.tsx @@ -0,0 +1,245 @@ +import React, { useState, useRef, useEffect } from 'react' +import { Play, Pause, Volume2, VolumeX, Maximize2, X } from 'lucide-react' +import { convertFileSrc } from '@tauri-apps/api/core' + +interface VideoPlayerProps { + videoPath: string + isOpen: boolean + onClose: () => void + title?: string +} + +const VideoPlayer: React.FC = ({ + videoPath, + isOpen, + onClose, + title = '视频播放' +}) => { + const videoRef = useRef(null) + const [isPlaying, setIsPlaying] = useState(false) + const [isMuted, setIsMuted] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [volume, setVolume] = useState(1) + const [isFullscreen, setIsFullscreen] = useState(false) + const [videoSrc, setVideoSrc] = useState('') + + useEffect(() => { + if (isOpen && videoPath) { + // 使用Tauri的convertFileSrc来获取安全的文件URL + const src = convertFileSrc(videoPath) + setVideoSrc(src) + } + }, [isOpen, videoPath]) + + useEffect(() => { + const video = videoRef.current + if (!video) return + + const handleTimeUpdate = () => setCurrentTime(video.currentTime) + const handleDurationChange = () => setDuration(video.duration) + const handleEnded = () => setIsPlaying(false) + const handlePlay = () => setIsPlaying(true) + const handlePause = () => setIsPlaying(false) + + video.addEventListener('timeupdate', handleTimeUpdate) + video.addEventListener('durationchange', handleDurationChange) + video.addEventListener('ended', handleEnded) + video.addEventListener('play', handlePlay) + video.addEventListener('pause', handlePause) + + return () => { + video.removeEventListener('timeupdate', handleTimeUpdate) + video.removeEventListener('durationchange', handleDurationChange) + video.removeEventListener('ended', handleEnded) + video.removeEventListener('play', handlePlay) + video.removeEventListener('pause', handlePause) + } + }, [videoSrc]) + + const handlePlayPause = () => { + const video = videoRef.current + if (!video) return + + if (isPlaying) { + video.pause() + } else { + video.play().catch(err => { + console.error('Failed to play video:', err) + }) + } + } + + const handleSeek = (e: React.ChangeEvent) => { + const video = videoRef.current + if (!video) return + + const newTime = parseFloat(e.target.value) + video.currentTime = newTime + setCurrentTime(newTime) + } + + const handleVolumeChange = (e: React.ChangeEvent) => { + const video = videoRef.current + if (!video) return + + const newVolume = parseFloat(e.target.value) + video.volume = newVolume + setVolume(newVolume) + setIsMuted(newVolume === 0) + } + + const handleMuteToggle = () => { + const video = videoRef.current + if (!video) return + + if (isMuted) { + video.volume = volume + setIsMuted(false) + } else { + video.volume = 0 + setIsMuted(true) + } + } + + const handleFullscreen = () => { + const video = videoRef.current + if (!video) return + + if (!isFullscreen) { + if (video.requestFullscreen) { + video.requestFullscreen() + setIsFullscreen(true) + } + } else { + if (document.exitFullscreen) { + document.exitFullscreen() + setIsFullscreen(false) + } + } + } + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60) + const seconds = Math.floor(time % 60) + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + + if (!isOpen) return null + + return ( +
+
+ {/* 关闭按钮 */} + + + {/* 标题 */} +
+

{title}

+
+ + {/* 视频容器 */} +
+
+
+ + +
+ ) +} + +export default VideoPlayer diff --git a/src/components/VideoSegmentCard.tsx b/src/components/VideoSegmentCard.tsx index 9bba8cb..defaad6 100644 --- a/src/components/VideoSegmentCard.tsx +++ b/src/components/VideoSegmentCard.tsx @@ -1,7 +1,8 @@ import React, { useState } from 'react' -import { Play, Pause, Video, Trash2, Tag, Eye, Clock, HardDrive, Hash, TrendingUp, X } from 'lucide-react' +import { Play, Video, Trash2, Tag, Eye, Clock, HardDrive, Hash, TrendingUp, X } from 'lucide-react' import { VideoSegment, MediaService } from '../services/mediaService' import ProjectImage from './ProjectImage' +import VideoPlayer from './VideoPlayer' interface VideoSegmentCardProps { segment: VideoSegment @@ -16,42 +17,12 @@ const VideoSegmentCard: React.FC = ({ onTagsUpdate, onUse }) => { - const [isPlaying, setIsPlaying] = useState(false) - const [videoElement, setVideoElement] = useState(null) + const [showVideoPlayer, setShowVideoPlayer] = useState(false) const [showTagEditor, setShowTagEditor] = useState(false) const [newTag, setNewTag] = useState('') - const handlePlayPause = () => { - if (!videoElement) { - // 创建视频元素 - const video_el = document.createElement('video') - video_el.src = `file://${segment.file_path}` - video_el.addEventListener('ended', () => setIsPlaying(false)) - video_el.addEventListener('error', (e) => { - console.error('Video playback error:', e) - setIsPlaying(false) - }) - setVideoElement(video_el) - - video_el.play() - .then(() => setIsPlaying(true)) - .catch(err => { - console.error('Failed to play video:', err) - setIsPlaying(false) - }) - } else { - if (isPlaying) { - videoElement.pause() - setIsPlaying(false) - } else { - videoElement.play() - .then(() => setIsPlaying(true)) - .catch(err => { - console.error('Failed to play video:', err) - setIsPlaying(false) - }) - } - } + const handlePlayVideo = () => { + setShowVideoPlayer(true) } const handleUse = () => { @@ -61,11 +32,6 @@ const VideoSegmentCard: React.FC = ({ } const handleDelete = () => { - if (videoElement) { - videoElement.pause() - setVideoElement(null) - setIsPlaying(false) - } onDelete(segment.id) } @@ -101,11 +67,11 @@ const VideoSegmentCard: React.FC = ({ {/* 播放按钮覆盖层 */}
@@ -196,7 +162,7 @@ const VideoSegmentCard: React.FC = ({ onChange={(e) => setNewTag(e.target.value)} placeholder="添加标签" className="flex-1 px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-transparent" - onKeyPress={(e) => e.key === 'Enter' && handleAddTag()} + onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} />