feat: 实现火山云视频预览和下载功能
新增功能: - 创建VideoPreviewModal组件,支持视频播放控制 - 实现视频预览功能,包括播放/暂停、静音、进度控制 - 添加视频下载功能,支持用户选择保存位置 - 集成到VideoGenerationTool中,替换原有的简单链接预览 技术实现: - 新增download_video_to_directory Tauri命令,支持文件选择对话框 - 使用Modal组件作为基础,确保一致的用户体验 - 实现完整的视频播放控制界面,包括进度条、音量控制 - 支持全屏播放和外部链接打开 - 添加错误处理和加载状态管理 用户体验改进: - 点击预览按钮打开专业的视频播放器界面 - 点击下载按钮可选择保存位置和文件名 - 播放控制包括快进/快退、静音等常用功能 - 响应式设计,适配不同屏幕尺寸 - 统一的通知系统反馈操作结果 代码优化: - 移除未使用的导入和变量 - 规范化错误处理和状态管理 - 遵循项目的TypeScript和React最佳实践
This commit is contained in:
parent
a8f720eba2
commit
e954fe2814
|
|
@ -245,7 +245,6 @@ impl ConnectionPool {
|
|||
|
||||
if let Some(index) = found_index {
|
||||
let pooled_conn = connections.remove(index).unwrap();
|
||||
println!("从连接池获取现有连接,剩余连接数: {}", connections.len());
|
||||
return Ok(Some(pooled_conn.connection));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -496,6 +496,7 @@ pub fn run() {
|
|||
commands::volcano_video_commands::delete_volcano_video_generation,
|
||||
commands::volcano_video_commands::batch_delete_volcano_video_generations,
|
||||
commands::volcano_video_commands::download_volcano_video,
|
||||
commands::volcano_video_commands::download_video_to_directory,
|
||||
commands::volcano_video_commands::batch_download_volcano_videos
|
||||
])
|
||||
.setup(|app| {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ pub async fn get_volcano_video_generations(
|
|||
state: State<'_, AppState>,
|
||||
query: Option<VideoGenerationQuery>,
|
||||
) -> Result<Vec<VideoGenerationRecord>, String> {
|
||||
info!("获取火山云视频生成记录列表");
|
||||
|
||||
// 获取数据库连接
|
||||
let database = {
|
||||
|
|
@ -73,7 +72,6 @@ pub async fn get_volcano_video_generations(
|
|||
// 获取记录列表
|
||||
match service.get_video_generations(query).await {
|
||||
Ok(records) => {
|
||||
info!("获取到 {} 条视频生成记录", records.len());
|
||||
Ok(records)
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -241,6 +239,87 @@ pub async fn download_volcano_video(
|
|||
}
|
||||
}
|
||||
|
||||
/// 下载视频到指定目录(带文件选择对话框)
|
||||
#[tauri::command]
|
||||
pub async fn download_video_to_directory(
|
||||
app: tauri::AppHandle,
|
||||
video_url: String,
|
||||
filename: String,
|
||||
) -> Result<String, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
info!("下载视频到指定目录: {} -> {}", video_url, filename);
|
||||
|
||||
// 获取文件扩展名
|
||||
let extension = if filename.ends_with(".mp4") {
|
||||
"mp4"
|
||||
} else {
|
||||
"mp4" // 默认使用mp4
|
||||
};
|
||||
|
||||
// 显示保存对话框
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
app.dialog().file()
|
||||
.set_title("保存视频文件")
|
||||
.set_file_name(&filename)
|
||||
.add_filter("视频文件", &[extension])
|
||||
.add_filter("所有文件", &["*"])
|
||||
.save_file(move |file_path| {
|
||||
let _ = tx.send(file_path);
|
||||
});
|
||||
|
||||
// 等待用户选择
|
||||
let file_path = match rx.recv_timeout(std::time::Duration::from_secs(30)) {
|
||||
Ok(Some(path)) => path.to_string(),
|
||||
Ok(None) => return Err("用户取消了保存操作".to_string()),
|
||||
Err(_) => return Err("文件选择对话框超时".to_string()),
|
||||
};
|
||||
|
||||
// 下载视频文件
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
match client.get(&video_url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
match response.bytes().await {
|
||||
Ok(bytes) => {
|
||||
// 确保目录存在
|
||||
if let Some(parent) = std::path::Path::new(&file_path).parent() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
return Err(format!("创建目录失败: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::fs::write(&file_path, bytes).await {
|
||||
Ok(()) => {
|
||||
info!("视频文件下载成功: {}", file_path);
|
||||
Ok(file_path)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("保存视频文件失败: {}", e);
|
||||
Err(format!("保存视频文件失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("读取视频数据失败: {}", e);
|
||||
Err(format!("读取视频数据失败: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error_msg = format!("下载失败,HTTP状态码: {}", response.status());
|
||||
error!("{}", error_msg);
|
||||
Err(error_msg)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("下载视频文件失败: {}", e);
|
||||
Err(format!("下载视频文件失败: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量下载视频文件
|
||||
#[tauri::command]
|
||||
pub async fn batch_download_volcano_videos(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Maximize,
|
||||
SkipBack,
|
||||
SkipForward
|
||||
} from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useNotifications } from './NotificationSystem';
|
||||
|
||||
/**
|
||||
* 视频预览模态框属性接口
|
||||
*/
|
||||
interface VideoPreviewModalProps {
|
||||
/** 是否显示模态框 */
|
||||
isOpen: boolean;
|
||||
/** 视频URL */
|
||||
videoUrl: string | null;
|
||||
/** 视频标题 */
|
||||
title?: string;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 下载回调 */
|
||||
onDownload?: (videoUrl: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频预览模态框组件
|
||||
* 支持视频播放控制、全屏、下载等功能
|
||||
*/
|
||||
export const VideoPreviewModal: React.FC<VideoPreviewModalProps> = ({
|
||||
isOpen,
|
||||
videoUrl,
|
||||
title = '视频预览',
|
||||
onClose,
|
||||
onDownload
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const { addNotification } = useNotifications();
|
||||
|
||||
// 重置状态当视频URL改变时
|
||||
useEffect(() => {
|
||||
if (videoUrl) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
}
|
||||
}, [videoUrl]);
|
||||
|
||||
// 播放/暂停控制
|
||||
const handlePlayPause = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play().catch(err => {
|
||||
console.error('播放失败:', err);
|
||||
setError('视频播放失败');
|
||||
});
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// 静音控制
|
||||
const handleMuteToggle = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const newMuted = !isMuted;
|
||||
videoRef.current.muted = newMuted;
|
||||
setIsMuted(newMuted);
|
||||
}, [isMuted]);
|
||||
|
||||
// 进度控制
|
||||
const handleSeek = useCallback((newTime: number) => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
videoRef.current.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}, []);
|
||||
|
||||
// 快进/快退
|
||||
const handleSkip = useCallback((seconds: number) => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
|
||||
handleSeek(newTime);
|
||||
}, [currentTime, duration, handleSeek]);
|
||||
|
||||
// 全屏控制
|
||||
const handleFullscreen = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (videoRef.current.requestFullscreen) {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}, [isFullscreen]);
|
||||
|
||||
// 下载视频
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!videoUrl) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
// 调用Tauri命令下载视频
|
||||
await invoke('download_video_to_directory', {
|
||||
videoUrl,
|
||||
filename: `video_${Date.now()}.mp4`
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '下载成功',
|
||||
message: '视频下载成功'
|
||||
});
|
||||
|
||||
if (onDownload) {
|
||||
onDownload(videoUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '下载失败',
|
||||
message: `视频下载失败: ${error}`
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [videoUrl, onDownload, addNotification]);
|
||||
|
||||
// 在新窗口打开
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
if (videoUrl) {
|
||||
window.open(videoUrl, '_blank');
|
||||
}
|
||||
}, [videoUrl]);
|
||||
|
||||
// 视频事件处理
|
||||
const handleVideoLoad = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVideoError = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setError('视频加载失败');
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
setIsPlaying(true);
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
}, []);
|
||||
|
||||
const handleFullscreenChange = useCallback(() => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
}, []);
|
||||
|
||||
// 监听全屏变化
|
||||
useEffect(() => {
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [handleFullscreenChange]);
|
||||
|
||||
// 格式化时间
|
||||
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 || !videoUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="xl"
|
||||
className="video-preview-modal"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 视频播放区域 */}
|
||||
<div className="relative bg-black rounded-lg overflow-hidden aspect-video">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 text-white">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 mb-2">⚠️</div>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="w-full h-full object-contain"
|
||||
onLoadedData={handleVideoLoad}
|
||||
onError={handleVideoError}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
controls={false}
|
||||
/>
|
||||
|
||||
{/* 视频控制覆盖层 */}
|
||||
{!isLoading && !error && (
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300">
|
||||
{/* 中央播放按钮 */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="bg-white/20 hover:bg-white/30 rounded-full p-4 transition-colors duration-200"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-8 h-8 text-white" />
|
||||
) : (
|
||||
<Play className="w-8 h-8 text-white ml-1" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底部控制栏 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<div className="space-y-2">
|
||||
{/* 进度条 */}
|
||||
<div className="flex items-center space-x-2 text-white text-sm">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<div className="flex-1 bg-white/20 rounded-full h-1">
|
||||
<div
|
||||
className="bg-blue-500 h-1 rounded-full transition-all duration-100"
|
||||
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleSkip(-10)}
|
||||
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||
title="后退10秒"
|
||||
>
|
||||
<SkipBack className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleSkip(10)}
|
||||
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||
title="前进10秒"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleMuteToggle}
|
||||
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
{isMuted ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleFullscreen}
|
||||
className="text-white hover:text-blue-400 transition-colors duration-200"
|
||||
title="全屏"
|
||||
>
|
||||
<Maximize className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors duration-200"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>{isDownloading ? '下载中...' : '下载视频'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenInNewWindow}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span>新窗口打开</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
{videoUrl && (
|
||||
<span className="truncate max-w-xs" title={videoUrl}>
|
||||
{videoUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPreviewModal;
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { useNotifications } from '../../components/NotificationSystem';
|
||||
import VideoPreviewModal from '../../components/VideoPreviewModal';
|
||||
|
||||
// 火山云视频生成相关类型定义
|
||||
interface VideoGenerationRecord {
|
||||
|
|
@ -72,6 +73,10 @@ const VideoGenerationTool: React.FC = () => {
|
|||
audio_url: '', // 驱动视频URL
|
||||
});
|
||||
|
||||
// 视频预览相关状态
|
||||
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||
const [previewVideoTitle, setPreviewVideoTitle] = useState<string>('');
|
||||
|
||||
// ============= 数据加载 =============
|
||||
|
||||
// 加载视频生成记录
|
||||
|
|
@ -231,6 +236,52 @@ const VideoGenerationTool: React.FC = () => {
|
|||
}
|
||||
}, [addNotification, loadRecords]);
|
||||
|
||||
// ============= 视频预览相关 =============
|
||||
|
||||
// 打开视频预览
|
||||
const handlePreviewVideo = useCallback((record: VideoGenerationRecord) => {
|
||||
if (record.result_video_url) {
|
||||
setPreviewVideoUrl(record.result_video_url);
|
||||
setPreviewVideoTitle(record.name || '视频预览');
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'warning',
|
||||
title: '预览失败',
|
||||
message: '该视频还未生成完成'
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 关闭视频预览
|
||||
const handleClosePreview = useCallback(() => {
|
||||
setPreviewVideoUrl(null);
|
||||
setPreviewVideoTitle('');
|
||||
}, []);
|
||||
|
||||
// 下载视频
|
||||
const handleDownloadVideo = useCallback(async (videoUrl: string) => {
|
||||
try {
|
||||
const filename = `volcano_video_${Date.now()}.mp4`;
|
||||
const savedPath = await invoke('download_video_to_directory', {
|
||||
videoUrl,
|
||||
filename
|
||||
});
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: '下载成功',
|
||||
message: `视频已保存到: ${savedPath}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('下载视频失败:', error);
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: '下载失败',
|
||||
message: `下载视频失败: ${error}`
|
||||
});
|
||||
}
|
||||
}, [addNotification]);
|
||||
|
||||
// 批量删除选中记录
|
||||
const batchDeleteRecords = useCallback(async () => {
|
||||
if (selectedRecords.length === 0) {
|
||||
|
|
@ -468,16 +519,14 @@ const VideoGenerationTool: React.FC = () => {
|
|||
{record.result_video_url && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => window.open(record.result_video_url, '_blank')}
|
||||
onClick={() => handlePreviewVideo(record)}
|
||||
className="text-blue-600 hover:text-blue-900 transition-colors duration-200"
|
||||
title="预览视频"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: 实现下载功能
|
||||
}}
|
||||
onClick={() => handleDownloadVideo(record.result_video_url!)}
|
||||
className="text-green-600 hover:text-green-900 transition-colors duration-200"
|
||||
title="下载视频"
|
||||
>
|
||||
|
|
@ -623,6 +672,15 @@ const VideoGenerationTool: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 视频预览模态框 */}
|
||||
<VideoPreviewModal
|
||||
isOpen={!!previewVideoUrl}
|
||||
videoUrl={previewVideoUrl}
|
||||
title={previewVideoTitle}
|
||||
onClose={handleClosePreview}
|
||||
onDownload={handleDownloadVideo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue