feat: smart flv conversion with progress (#155)

* fix: auto-close batch delete dialog after completion

* feat: smart FLV conversion with detailed progress and better UX

- Intelligent FLV→MP4 conversion (lossless stream copy + high-quality fallback)
- Real-time import progress with percentage tracking
- Smart file size display (auto GB/MB units)
- Optimized thumbnail generation and network file handling

* refactor: reorganize FFmpeg functions and fix network detection

- Move FFmpeg functions from video.rs to ffmpeg.rs
- Fix Windows drive letters misidentified as network paths
- Improve network vs local file detection logic

* fix: delete thumbnails when removing cliped videos
This commit is contained in:
Eeeeep4
2025-08-10 17:00:31 +08:00
committed by GitHub
parent 9fea18f2de
commit d2d9112f6c
5 changed files with 584 additions and 33 deletions

View File

@@ -985,6 +985,165 @@ pub async fn generate_thumbnail(
Ok(())
}
// 解析FFmpeg时间字符串为秒数 (格式: "HH:MM:SS.mmm")
pub fn parse_ffmpeg_time(time_str: &str) -> Result<f64, String> {
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() != 3 {
return Err(format!("Invalid time format: {}", time_str));
}
let hours: f64 = parts[0].parse().map_err(|_| format!("Invalid hours: {}", parts[0]))?;
let minutes: f64 = parts[1].parse().map_err(|_| format!("Invalid minutes: {}", parts[1]))?;
let seconds: f64 = parts[2].parse().map_err(|_| format!("Invalid seconds: {}", parts[2]))?;
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
}
// 执行FFmpeg转换的通用函数
pub async fn execute_ffmpeg_conversion(
mut cmd: tokio::process::Command,
total_duration: f64,
reporter: &ProgressReporter,
mode_name: &str,
) -> Result<(), String> {
use std::process::Stdio;
use tokio::io::BufReader;
use async_ffmpeg_sidecar::event::FfmpegEvent;
use async_ffmpeg_sidecar::log_parser::FfmpegLogParser;
let mut child = cmd
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("启动FFmpeg进程失败: {}", e))?;
let stderr = child.stderr.take().unwrap();
let reader = BufReader::new(stderr);
let mut parser = FfmpegLogParser::new(reader);
let mut conversion_error = None;
while let Ok(event) = parser.parse_next_event().await {
match event {
FfmpegEvent::Progress(p) => {
if total_duration > 0.0 {
// 解析时间字符串为浮点数 (格式: "HH:MM:SS.mmm")
if let Ok(current_time) = parse_ffmpeg_time(&p.time) {
let progress = (current_time / total_duration * 100.0).min(100.0);
reporter.update(&format!("正在转换视频格式... {:.1}% ({})", progress, mode_name));
} else {
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
}
} else {
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
}
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(level, content) => {
if matches!(level, async_ffmpeg_sidecar::event::LogLevel::Error) && content.contains("Error") {
conversion_error = Some(content);
}
}
FfmpegEvent::Error(e) => {
conversion_error = Some(e);
}
_ => {} // 忽略其他事件类型
}
}
let status = child.wait().await.map_err(|e| format!("等待FFmpeg进程失败: {}", e))?;
if !status.success() {
let error_msg = conversion_error.unwrap_or_else(|| format!("FFmpeg退出码: {}", status.code().unwrap_or(-1)));
return Err(format!("视频格式转换失败 ({}): {}", mode_name, error_msg));
}
reporter.update(&format!("视频格式转换完成 100% ({})", mode_name));
Ok(())
}
// 尝试流复制转换(无损,速度快)
pub async fn try_stream_copy_conversion(
source: &Path,
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
// 获取视频时长以计算进度
let metadata = extract_video_metadata(source).await?;
let total_duration = metadata.duration;
reporter.update("正在转换视频格式... 0% (无损模式)");
// 构建ffmpeg命令 - 流复制模式
let mut cmd = tokio::process::Command::new(ffmpeg_path());
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd.args([
"-i", &source.to_string_lossy(),
"-c:v", "copy", // 直接复制视频流,零损失
"-c:a", "copy", // 直接复制音频流,零损失
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
"-movflags", "+faststart", // 优化web播放
"-progress", "pipe:2", // 输出进度到stderr
"-y", // 覆盖输出文件
&dest.to_string_lossy(),
]);
execute_ffmpeg_conversion(cmd, total_duration, reporter, "无损转换").await
}
// 高质量重编码转换(兼容性好,质量高)
pub async fn try_high_quality_conversion(
source: &Path,
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
// 获取视频时长以计算进度
let metadata = extract_video_metadata(source).await?;
let total_duration = metadata.duration;
reporter.update("正在转换视频格式... 0% (高质量模式)");
// 构建ffmpeg命令 - 高质量重编码
let mut cmd = tokio::process::Command::new(ffmpeg_path());
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
cmd.args([
"-i", &source.to_string_lossy(),
"-c:v", "libx264", // H.264编码器
"-preset", "slow", // 慢速预设,更好的压缩效率
"-crf", "18", // 高质量设置 (18-23范围越小质量越高)
"-c:a", "aac", // AAC音频编码器
"-b:a", "192k", // 高音频码率
"-avoid_negative_ts", "make_zero", // 修复时间戳问题
"-movflags", "+faststart", // 优化web播放
"-progress", "pipe:2", // 输出进度到stderr
"-y", // 覆盖输出文件
&dest.to_string_lossy(),
]);
execute_ffmpeg_conversion(cmd, total_duration, reporter, "高质量转换").await
}
// 带进度的视频格式转换函数(智能质量保持策略)
pub async fn convert_video_format(
source: &Path,
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
// 先尝试stream copy无损转换如果失败则使用高质量重编码
match try_stream_copy_conversion(source, dest, reporter).await {
Ok(()) => Ok(()),
Err(stream_copy_error) => {
reporter.update("流复制失败,使用高质量重编码模式...");
log::warn!("Stream copy failed: {}, falling back to re-encoding", stream_copy_error);
try_high_quality_conversion(source, dest, reporter).await
}
}
}
// tests
#[cfg(test)]
mod tests {

View File

@@ -11,6 +11,67 @@ use crate::subtitle_generator::item_to_srt;
use chrono::{Local, Utc};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::io::{Read, Write};
use std::fs::File;
// 检测是否为网络协议路径排除Windows盘符
fn is_network_protocol(path_str: &str) -> bool {
// 常见的网络协议
let network_protocols = [
"ftp://", "sftp://", "ftps://",
"http://", "https://",
"smb://", "cifs://",
"nfs://", "afp://",
"ssh://", "scp://",
];
// 检查是否以网络协议开头
for protocol in &network_protocols {
if path_str.to_lowercase().starts_with(protocol) {
return true;
}
}
// 排除Windows盘符格式 (如 C:/, D:/, E:/ 等)
if cfg!(windows) {
// 检查是否为Windows盘符格式单字母 + : + /
if path_str.len() >= 3 {
let chars: Vec<char> = path_str.chars().collect();
if chars.len() >= 3 &&
chars[0].is_ascii_alphabetic() &&
chars[1] == ':' &&
(chars[2] == '/' || chars[2] == '\\') {
return false; // 这是Windows盘符不是网络路径
}
}
}
false
}
// 判断是否需要转换视频格式
fn should_convert_video_format(extension: &str) -> bool {
// FLV格式在现代浏览器中播放兼容性差需要转换为MP4
matches!(extension.to_lowercase().as_str(), "flv")
}
// 获取最佳缩略图时间点
fn get_optimal_thumbnail_timestamp(duration: f64) -> f64 {
// 根据视频长度选择最佳时间点
if duration <= 10.0 {
// 短视频10秒以内选择1/3位置避免开头黑屏
duration / 3.0
} else if duration <= 60.0 {
// 1分钟以内选择第3秒
3.0
} else if duration <= 300.0 {
// 5分钟以内选择第5秒
5.0
} else {
// 长视频选择第10秒确保跳过开头可能的黑屏/logo
10.0
}
}
use crate::state::State;
use crate::state_type;
@@ -26,6 +87,207 @@ struct ImportedVideoMetadata {
resolution: Option<String>,
}
// 带进度的文件复制函数
async fn copy_file_with_progress(
source: &Path,
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
let mut source_file = File::open(source).map_err(|e| format!("无法打开源文件: {}", e))?;
let mut dest_file = File::create(dest).map_err(|e| format!("无法创建目标文件: {}", e))?;
let total_size = source_file.metadata().map_err(|e| format!("无法获取文件大小: {}", e))?.len();
let mut copied = 0u64;
// 根据文件大小调整缓冲区大小
let buffer_size = if total_size > 100 * 1024 * 1024 { // 100MB以上
1024 * 1024 // 1MB buffer for large files
} else if total_size > 10 * 1024 * 1024 { // 10MB-100MB
256 * 1024 // 256KB buffer
} else {
64 * 1024 // 64KB buffer for small files
};
let mut buffer = vec![0u8; buffer_size];
let mut last_reported_percent = 0;
loop {
let bytes_read = source_file.read(&mut buffer).map_err(|e| format!("读取文件失败: {}", e))?;
if bytes_read == 0 {
break;
}
dest_file.write_all(&buffer[..bytes_read]).map_err(|e| format!("写入文件失败: {}", e))?;
copied += bytes_read as u64;
// 计算进度百分比,只在变化时更新
let percent = if total_size > 0 {
((copied as f64 / total_size as f64) * 100.0) as u32
} else {
0
};
// 根据文件大小调整报告频率
let report_threshold = if total_size > 100 * 1024 * 1024 { // 100MB以上
1 // 每1%报告一次
} else if total_size > 10 * 1024 * 1024 { // 10MB-100MB
2 // 每2%报告一次
} else {
5 // 小文件每5%报告一次
};
if percent != last_reported_percent && (percent % report_threshold == 0 || percent == 100) {
reporter.update(&format!("正在复制视频文件... {}%", percent));
last_reported_percent = percent;
}
}
dest_file.flush().map_err(|e| format!("刷新文件缓冲区失败: {}", e))?;
Ok(())
}
// 智能边拷贝边转换函数(针对网络文件优化)
async fn copy_and_convert_with_progress(
source: &Path,
dest: &Path,
need_conversion: bool,
reporter: &ProgressReporter,
) -> Result<(), String> {
if !need_conversion {
// 非转换文件直接使用原有拷贝逻辑
return copy_file_with_progress(source, dest, reporter).await;
}
// 检查源文件是否在网络位置(启发式判断)
let source_str = source.to_string_lossy();
let is_network_source = source_str.starts_with("\\\\") || // UNC path (Windows网络共享)
is_network_protocol(&source_str); // 网络协议但排除Windows盘符
if is_network_source {
// 网络文件:先复制到本地临时位置,再转换
reporter.update("检测到网络文件,使用先复制后转换策略...");
copy_then_convert_strategy(source, dest, reporter).await
} else {
// 本地文件:直接转换(更高效)
reporter.update("检测到本地文件,使用直接转换策略...");
ffmpeg::convert_video_format(source, dest, reporter).await
}
}
// 网络文件处理策略:先复制到本地临时位置,再转换
async fn copy_then_convert_strategy(
source: &Path,
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
// 创建临时文件路径
let temp_dir = std::env::temp_dir();
let temp_filename = format!("temp_video_{}.{}",
chrono::Utc::now().timestamp(),
source.extension().and_then(|e| e.to_str()).unwrap_or("tmp"));
let temp_path = temp_dir.join(&temp_filename);
// 确保临时目录存在
if let Some(parent) = temp_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建临时目录失败: {}", e))?;
}
// 第一步:将网络文件复制到本地临时位置(使用优化的缓冲区)
reporter.update("第1步从网络复制文件到本地临时位置...");
copy_file_with_network_optimization(source, &temp_path, reporter).await?;
// 第二步:从本地临时文件转换到目标位置
reporter.update("第2步从临时文件转换到目标格式...");
let convert_result = ffmpeg::convert_video_format(&temp_path, dest, reporter).await;
// 清理临时文件
if temp_path.exists() {
if let Err(e) = std::fs::remove_file(&temp_path) {
log::warn!("删除临时文件失败: {} - {}", temp_path.display(), e);
} else {
log::info!("已清理临时文件: {}", temp_path.display());
}
}
convert_result
}
// 针对网络文件优化的复制函数
async fn copy_file_with_network_optimization(
source: &Path,
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
let mut source_file = File::open(source).map_err(|e| format!("无法打开网络源文件: {}", e))?;
let mut dest_file = File::create(dest).map_err(|e| format!("无法创建本地临时文件: {}", e))?;
let total_size = source_file.metadata().map_err(|e| format!("无法获取文件大小: {}", e))?.len();
let mut copied = 0u64;
// 网络文件使用更大的缓冲区以减少网络请求次数
let buffer_size = if total_size > 1024 * 1024 * 1024 { // >1GB
8 * 1024 * 1024 // 8MB buffer for very large files
} else if total_size > 100 * 1024 * 1024 { // >100MB
4 * 1024 * 1024 // 4MB buffer for large files
} else {
2 * 1024 * 1024 // 2MB buffer for network files
};
let mut buffer = vec![0u8; buffer_size];
let mut last_reported_percent = 0;
let mut consecutive_errors = 0;
const MAX_RETRIES: u32 = 3;
loop {
match source_file.read(&mut buffer) {
Ok(bytes_read) => {
if bytes_read == 0 {
break; // 文件读取完成
}
// 重置错误计数
consecutive_errors = 0;
dest_file.write_all(&buffer[..bytes_read]).map_err(|e| format!("写入临时文件失败: {}", e))?;
copied += bytes_read as u64;
// 计算并报告进度
let percent = if total_size > 0 {
((copied as f64 / total_size as f64) * 100.0) as u32
} else {
0
};
// 网络文件更频繁地报告进度
if percent != last_reported_percent {
reporter.update(&format!("正在从网络复制文件... {}% ({:.1}MB/{:.1}MB)",
percent,
copied as f64 / (1024.0 * 1024.0),
total_size as f64 / (1024.0 * 1024.0)));
last_reported_percent = percent;
}
}
Err(e) => {
consecutive_errors += 1;
log::warn!("网络读取错误 (尝试 {}/{}): {}", consecutive_errors, MAX_RETRIES, e);
if consecutive_errors >= MAX_RETRIES {
return Err(format!("网络文件读取失败,已重试{}次: {}", MAX_RETRIES, e));
}
// 等待一小段时间后重试
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
reporter.update(&format!("网络连接中断,正在重试... ({}/{})", consecutive_errors, MAX_RETRIES));
}
}
}
dest_file.flush().map_err(|e| format!("刷新临时文件缓冲区失败: {}", e))?;
reporter.update("网络文件复制完成");
Ok(())
}
#[derive(serde::Serialize, serde::Deserialize)]
struct ClipMetadata {
parent_video_id: i64,
@@ -356,27 +618,56 @@ pub async fn get_video_cover(state: state_type!(), id: i64) -> Result<String, St
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn delete_video(state: state_type!(), id: i64) -> Result<(), String> {
// get video info from dbus
// get video info from db
let video = state.db.get_video(id).await?;
let config = state.config.read().await;
// delete video from db
state.db.delete_video(id).await?;
// delete video files
let filepath = Path::new(state.config.read().await.output.as_str()).join(&video.file);
let filepath = Path::new(&config.output).join(&video.file);
let file = Path::new(&filepath);
let _ = std::fs::remove_file(file);
if let Err(e) = std::fs::remove_file(file) {
log::warn!("删除视频文件失败: {} - {}", file.display(), e);
} else {
log::info!("已删除视频文件: {}", file.display());
}
// delete srt file
// delete subtitle files
let srt_path = file.with_extension("srt");
let _ = std::fs::remove_file(srt_path);
// delete wav file
let wav_path = file.with_extension("wav");
let _ = std::fs::remove_file(wav_path);
// delete mp3 file
let mp3_path = file.with_extension("mp3");
let _ = std::fs::remove_file(mp3_path);
// delete thumbnail file based on video type
delete_video_thumbnail(&config.output, &video).await;
Ok(())
}
// 根据视频类型删除对应的缩略图文件
async fn delete_video_thumbnail(output_dir: &str, video: &VideoRow) {
if video.cover.is_empty() {
return; // 没有缩略图,无需删除
}
// 构建缩略图完整路径
let thumbnail_path = Path::new(output_dir).join(&video.cover);
if thumbnail_path.exists() {
if let Err(e) = std::fs::remove_file(&thumbnail_path) {
log::warn!("删除缩略图失败: {} - {}", thumbnail_path.display(), e);
} else {
log::info!("已删除缩略图: {}", thumbnail_path.display());
}
} else {
log::debug!("缩略图文件不存在: {}", thumbnail_path.display());
}
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_video_typelist(
state: state_type!(),
@@ -636,12 +927,20 @@ pub async fn generic_ffmpeg_command(
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn import_external_video(
state: state_type!(),
event_id: String,
file_path: String,
title: String,
_original_name: String,
size: i64,
room_id: u64,
) -> Result<VideoRow, String> {
// 设置进度事件发射器
#[cfg(feature = "gui")]
let emitter = EventEmitter::new(state.app_handle.clone());
#[cfg(feature = "headless")]
let emitter = EventEmitter::new(state.progress_manager.get_event_sender());
let reporter = ProgressReporter::new(&emitter, &event_id).await?;
let source_path = Path::new(&file_path);
// 验证文件存在
@@ -649,7 +948,8 @@ pub async fn import_external_video(
return Err("文件不存在".to_string());
}
// 获取视频元数据
// 步骤1: 获取视频元数据
reporter.update("正在提取视频元数据...");
let metadata = ffmpeg::extract_video_metadata(source_path).await?;
// 生成目标文件名
@@ -663,25 +963,45 @@ pub async fn import_external_video(
let extension = source_path.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("mp4");
let target_filename = format!("imported_{}_{}.{}", timestamp,
let mut target_filename = format!("imported_{}_{}.{}", timestamp,
sanitize_filename(&title), extension);
let target_path = output_dir.join(&target_filename);
// 复制文件到目标位置
std::fs::copy(source_path, &target_path).map_err(|e| e.to_string())?;
// 步骤2: 智能复制或转换文件到目标位置
let need_conversion = should_convert_video_format(&extension);
let final_target_path = if need_conversion {
// FLV文件需要转换为MP4
let mp4_filename = format!("imported_{}_{}.mp4", timestamp,
sanitize_filename(&title));
let mp4_target_path = output_dir.join(&mp4_filename);
reporter.update("准备转换视频格式 (FLV → MP4)...");
// 使用智能转换函数,自动检测网络优化
copy_and_convert_with_progress(source_path, &mp4_target_path, true, &reporter).await?;
// 更新最终文件名和路径
target_filename = mp4_filename;
mp4_target_path
} else {
// 其他格式使用智能拷贝
copy_and_convert_with_progress(source_path, &target_path, false, &reporter).await?;
target_path
};
// 生成缩略图
// 步骤3: 生成缩略图
reporter.update("正在生成视频缩略图...");
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("imported");
if !thumbnail_dir.exists() {
std::fs::create_dir_all(&thumbnail_dir).map_err(|e| e.to_string())?;
}
let thumbnail_filename = format!("{}.jpg",
target_path.file_stem().unwrap().to_str().unwrap());
final_target_path.file_stem().unwrap().to_str().unwrap());
let thumbnail_path = thumbnail_dir.join(&thumbnail_filename);
// 生成缩略图,如果失败则使用默认封面
let cover_path = match ffmpeg::generate_thumbnail(&target_path, &thumbnail_path, metadata.duration / 2.0).await {
// 生成缩略图,使用智能时间点选择
let thumbnail_timestamp = get_optimal_thumbnail_timestamp(metadata.duration);
let cover_path = match ffmpeg::generate_thumbnail(&final_target_path, &thumbnail_path, thumbnail_timestamp).await {
Ok(_) => format!("thumbnails/imported/{}", thumbnail_filename),
Err(e) => {
log::warn!("生成缩略图失败: {}", e);
@@ -689,6 +1009,9 @@ pub async fn import_external_video(
}
};
// 步骤4: 保存到数据库
reporter.update("正在保存视频信息...");
// 构建导入视频的元数据
let import_metadata = ImportedVideoMetadata {
original_path: file_path.clone(),
@@ -707,7 +1030,7 @@ pub async fn import_external_video(
title,
file: format!("imported/{}", target_filename), // 包含完整相对路径
length: metadata.duration as i64,
size: target_path.metadata().map_err(|e| e.to_string())?.len() as i64,
size: final_target_path.metadata().map_err(|e| e.to_string())?.len() as i64,
status: 1, // 导入完成
cover: cover_path,
desc: serde_json::to_string(&import_metadata).unwrap_or_default(),
@@ -719,12 +1042,16 @@ pub async fn import_external_video(
let result = state.db.add_video(&video).await?;
// 完成进度通知
reporter.finish(true, "视频导入完成").await;
// 发送通知消息
state.db.new_message(
"视频导入完成",
&format!("成功导入视频:{}", result.title),
).await?;
log::info!("导入视频成功: {} -> {}", file_path, result.file);
Ok(result)
}
@@ -847,8 +1174,10 @@ async fn clip_video_inner(
};
let thumbnail_path = thumbnail_dir.join(&clip_thumbnail_filename);
// 生成缩略图,如果失败则使用默认封面
let clip_cover_path = match ffmpeg::generate_thumbnail(&output_path, &thumbnail_path, (end_time - start_time) / 2.0).await {
// 生成缩略图,选择切片开头的合理位置
let clip_duration = end_time - start_time;
let clip_thumbnail_timestamp = get_optimal_thumbnail_timestamp(clip_duration);
let clip_cover_path = match ffmpeg::generate_thumbnail(&output_path, &thumbnail_path, clip_thumbnail_timestamp).await {
Ok(_) => format!("thumbnails/clips/{}", clip_thumbnail_filename),
Err(e) => {
log::warn!("生成切片缩略图失败: {}", e);

View File

@@ -930,7 +930,7 @@ async fn handler_import_external_video(
state: axum::extract::State<State>,
Json(param): Json<ImportExternalVideoRequest>,
) -> Result<Json<ApiResponse<String>>, ApiError> {
import_external_video(state.0, param.file_path.clone(), param.title, param.original_name, param.size, param.room_id).await?;
import_external_video(state.0, param.event_id.clone(), param.file_path.clone(), param.title, param.original_name, param.size, param.room_id).await?;
Ok(Json(ApiResponse::success(param.event_id)))
}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { invoke, TAURI_ENV, ENDPOINT } from "../lib/invoker";
import { invoke, TAURI_ENV, ENDPOINT, listen } from "../lib/invoker";
import { Upload, X, CheckCircle } from "lucide-svelte";
import { createEventDispatcher } from "svelte";
import { createEventDispatcher, onDestroy } from "svelte";
import { open } from "@tauri-apps/plugin-dialog";
import type { ProgressUpdate, ProgressFinished } from "./interface";
export let showDialog = false;
export let roomId: number | null = null;
@@ -18,6 +19,60 @@
let uploadProgress = 0;
let dragOver = false;
let fileInput: HTMLInputElement;
let importProgress = "";
let currentImportEventId: string | null = null;
// 格式化文件大小
function formatFileSize(sizeInBytes: number): string {
if (sizeInBytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const k = 1024;
let unitIndex = 0;
let size = sizeInBytes;
// 找到合适的单位
while (size >= k && unitIndex < units.length - 1) {
size /= k;
unitIndex++;
}
// 对于GB以上显示2位小数MB显示2位小数KB及以下显示1位小数
const decimals = unitIndex >= 3 ? 2 : (unitIndex >= 2 ? 2 : 1);
return size.toFixed(decimals) + " " + units[unitIndex];
}
// 进度监听器
const progressUpdateListener = listen<ProgressUpdate>('progress-update', (e) => {
if (e.payload.id === currentImportEventId) {
importProgress = e.payload.content;
}
});
const progressFinishedListener = listen<ProgressFinished>('progress-finished', (e) => {
if (e.payload.id === currentImportEventId) {
if (e.payload.success) {
// 导入成功,关闭对话框并刷新列表
showDialog = false;
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
dispatch("imported");
} else {
alert("导入失败: " + e.payload.message);
}
importing = false;
currentImportEventId = null;
importProgress = "";
}
});
onDestroy(() => {
progressUpdateListener?.then(fn => fn());
progressFinishedListener?.then(fn => fn());
});
async function handleFileSelect() {
if (TAURI_ENV) {
@@ -151,8 +206,12 @@
if (!selectedFilePath) return;
importing = true;
importProgress = "准备导入...";
try {
const eventId = "import_" + Date.now();
currentImportEventId = eventId;
await invoke("import_external_video", {
eventId: eventId,
filePath: selectedFilePath,
@@ -162,20 +221,13 @@
roomId: roomId || 0
});
// 导入成功,关闭对话框并刷新列表
showDialog = false;
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
dispatch("imported");
// 注意成功处理移到了progressFinishedListener中
} catch (error) {
console.error("导入失败:", error);
alert("导入失败: " + error);
} finally {
importing = false;
uploading = false;
uploadProgress = 0;
currentImportEventId = null;
importProgress = "";
}
}
@@ -187,6 +239,9 @@
videoTitle = "";
uploading = false;
uploadProgress = 0;
importing = false;
currentImportEventId = null;
importProgress = "";
}
function handleDragOver(event: DragEvent) {
@@ -251,7 +306,7 @@
<CheckCircle class="w-12 h-12 text-green-500 mx-auto" />
</div>
<p class="text-sm text-gray-900 dark:text-white font-medium">{selectedFileName}</p>
<p class="text-xs text-gray-500">大小: {(selectedFileSize / 1024 / 1024).toFixed(2)} MB</p>
<p class="text-xs text-gray-500">大小: {formatFileSize(selectedFileSize)}</p>
<p class="text-xs text-gray-400 break-all" title={selectedFilePath}>{selectedFilePath}</p>
<button
on:click={() => {
@@ -327,9 +382,15 @@
<button
on:click={startImport}
disabled={!selectedFilePath || importing || !videoTitle.trim() || uploading}
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
{importing ? "导入中..." : "开始导入"}
{#if importing}
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{/if}
<span>{importing ? (importProgress || "导入中...") : "开始导入"}</span>
</button>
</div>
</div>

View File

@@ -238,6 +238,8 @@
}
selectedVideos.clear();
await loadVideos();
showDeleteConfirm = false;
videoToDelete = null;
} catch (error) {
console.error("Failed to delete selected videos:", error);
}