feat: clip note support (#173)

* refactor: move components

* feat: note for clip (close #170)

* fix: import video handler

* fix: sort by note

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Xinrea
2025-08-30 10:24:53 +08:00
committed by GitHub
parent c269558bae
commit d2a9c44601
35 changed files with 1475 additions and 1157 deletions

View File

@@ -0,0 +1,4 @@
pub const PREFIX_SUBTITLE: &str = "[subtitle]";
pub const PREFIX_IMPORTED: &str = "[imported]";
pub const PREFIX_DANMAKU: &str = "[danmaku]";
pub const PREFIX_CLIP: &str = "[clip]";

View File

@@ -8,6 +8,7 @@ pub struct VideoRow {
pub room_id: u64,
pub cover: String,
pub file: String,
pub note: String,
pub length: i64,
pub size: i64,
pub status: i64,
@@ -25,6 +26,7 @@ pub struct VideoNoCover {
pub id: i64,
pub room_id: u64,
pub file: String,
pub note: String,
pub length: i64,
pub size: i64,
pub status: i64,
@@ -59,13 +61,14 @@ impl Database {
pub async fn update_video(&self, video_row: &VideoRow) -> Result<(), DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6 WHERE id = $7")
sqlx::query("UPDATE videos SET status = $1, bvid = $2, title = $3, desc = $4, tags = $5, area = $6, note = $7 WHERE id = $8")
.bind(video_row.status)
.bind(&video_row.bvid)
.bind(&video_row.title)
.bind(&video_row.desc)
.bind(&video_row.tags)
.bind(video_row.area)
.bind(&video_row.note)
.bind(video_row.id)
.execute(&lock)
.await?;
@@ -83,10 +86,11 @@ impl Database {
pub async fn add_video(&self, video: &VideoRow) -> Result<VideoRow, DatabaseError> {
let lock = self.db.read().await.clone().unwrap();
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, length, size, status, bvid, title, desc, tags, area, created_at, platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)")
let sql = sqlx::query("INSERT INTO videos (room_id, cover, file, note, length, size, status, bvid, title, desc, tags, area, created_at, platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)")
.bind(video.room_id as i64)
.bind(&video.cover)
.bind(&video.file)
.bind(&video.note)
.bind(video.length)
.bind(video.size)
.bind(video.status)

View File

@@ -2,6 +2,7 @@ use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use crate::constants;
use crate::progress_reporter::{ProgressReporter, ProgressReporterTrait};
use crate::subtitle_generator::whisper_online;
use crate::subtitle_generator::{
@@ -363,6 +364,7 @@ pub async fn get_segment_duration(file: &Path) -> Result<f64, String> {
duration.ok_or_else(|| "Failed to parse segment duration".to_string())
}
/// Encode video subtitle using ffmpeg, output is file name with prefix [subtitle]
pub async fn encode_video_subtitle(
reporter: &impl ProgressReporterTrait,
file: &Path,
@@ -371,9 +373,13 @@ pub async fn encode_video_subtitle(
) -> Result<String, String> {
// ffmpeg -i fixed_\[30655190\]1742887114_0325084106_81.5.mp4 -vf "subtitles=test.srt:force_style='FontSize=24'" -c:v libx264 -c:a copy output.mp4
log::info!("Encode video subtitle task start: {}", file.display());
log::info!("srt_style: {}", srt_style);
log::info!("SRT style: {}", srt_style);
// output path is file with prefix [subtitle]
let output_filename = format!("[subtitle]{}", file.file_name().unwrap().to_str().unwrap());
let output_filename = format!(
"{}{}",
constants::PREFIX_SUBTITLE,
file.file_name().unwrap().to_str().unwrap()
);
let output_path = file.with_file_name(&output_filename);
// check output path exists - log but allow overwrite
@@ -462,14 +468,18 @@ pub async fn encode_video_danmu(
) -> Result<PathBuf, String> {
// ffmpeg -i fixed_\[30655190\]1742887114_0325084106_81.5.mp4 -vf ass=subtitle.ass -c:v libx264 -c:a copy output.mp4
log::info!("Encode video danmu task start: {}", file.display());
let danmu_filename = format!("[danmu]{}", file.file_name().unwrap().to_str().unwrap());
let output_path = file.with_file_name(danmu_filename);
let danmu_filename = format!(
"{}{}",
constants::PREFIX_DANMAKU,
file.file_name().unwrap().to_str().unwrap()
);
let output_file_path = file.with_file_name(danmu_filename);
// check output path exists - log but allow overwrite
if output_path.exists() {
if output_file_path.exists() {
log::info!(
"Output path already exists, will overwrite: {}",
output_path.display()
output_file_path.display()
);
}
@@ -497,7 +507,7 @@ pub async fn encode_video_danmu(
.args(["-vf", &format!("ass={}", subtitle)])
.args(["-c:v", "libx264"])
.args(["-c:a", "copy"])
.args([output_path.to_str().unwrap()])
.args([output_file_path.to_str().unwrap()])
.args(["-y"])
.args(["-progress", "pipe:2"])
.stderr(Stdio::piped())
@@ -542,8 +552,11 @@ pub async fn encode_video_danmu(
log::error!("Encode video danmu error: {}", error);
Err(error)
} else {
log::info!("Encode video danmu task end: {}", output_path.display());
Ok(output_path)
log::info!(
"Encode video danmu task end: {}",
output_file_path.display()
);
Ok(output_file_path)
}
}
@@ -810,20 +823,6 @@ fn ffprobe_path() -> PathBuf {
path
}
// 解析 FFmpeg 时间字符串 (格式如 "00:01:23.45")
fn parse_time_string(time_str: &str) -> Result<f64, String> {
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() != 3 {
return Err("Invalid time format".to_string());
}
let hours: f64 = parts[0].parse().map_err(|_| "Invalid hours")?;
let minutes: f64 = parts[1].parse().map_err(|_| "Invalid minutes")?;
let seconds: f64 = parts[2].parse().map_err(|_| "Invalid seconds")?;
Ok(hours * 3600.0 + minutes * 60.0 + seconds)
}
// 从视频文件切片
pub async fn clip_from_video_file(
reporter: Option<&impl ProgressReporterTrait>,
@@ -869,11 +868,7 @@ pub async fn clip_from_video_file(
match event {
FfmpegEvent::Progress(p) => {
if let Some(reporter) = reporter {
// 解析时间字符串 (格式如 "00:01:23.45")
if let Ok(current_time) = parse_time_string(&p.time) {
let progress = (current_time / duration * 100.0).min(100.0);
reporter.update(&format!("切片进度: {:.1}%", progress));
}
reporter.update(&format!("切片进度: {}", p.time));
}
}
FfmpegEvent::LogEOF => break,
@@ -902,7 +897,13 @@ pub async fn clip_from_video_file(
}
}
// 获取视频元数据
/// Extract basic information from a video file.
///
/// # Arguments
/// * `file_path` - The path to the video file.
///
/// # Returns
/// A `Result` containing the video metadata or an error message.
pub async fn extract_video_metadata(file_path: &Path) -> Result<VideoMetadata, String> {
let mut ffprobe_process = tokio::process::Command::new("ffprobe");
#[cfg(target_os = "windows")]
@@ -960,26 +961,26 @@ pub async fn extract_video_metadata(file_path: &Path) -> Result<VideoMetadata, S
})
}
// 生成视频缩略图
pub async fn generate_thumbnail(
video_path: &Path,
output_path: &Path,
timestamp: f64,
) -> Result<(), String> {
let output_folder = output_path.parent().unwrap();
if !output_folder.exists() {
std::fs::create_dir_all(output_folder).unwrap();
}
/// Generate thumbnail file from video, capturing a frame at the specified timestamp.
///
/// # Arguments
/// * `video_full_path` - The full path to the video file.
/// * `timestamp` - The timestamp (in seconds) to capture the thumbnail.
///
/// # Returns
/// The path to the generated thumbnail image.
pub async fn generate_thumbnail(video_full_path: &Path, timestamp: f64) -> Result<PathBuf, String> {
let mut ffmpeg_process = tokio::process::Command::new(ffmpeg_path());
#[cfg(target_os = "windows")]
ffmpeg_process.creation_flags(CREATE_NO_WINDOW);
let thumbnail_full_path = video_full_path.with_extension("jpg");
let output = ffmpeg_process
.args(["-i", &format!("{}", video_path.display())])
.args(["-i", &format!("{}", video_full_path.display())])
.args(["-ss", &timestamp.to_string()])
.args(["-vframes", "1"])
.args(["-y", output_path.to_str().unwrap()])
.args(["-y", thumbnail_full_path.to_str().unwrap()])
.output()
.await
.map_err(|e| format!("生成缩略图失败: {}", e))?;
@@ -992,42 +993,21 @@ pub async fn generate_thumbnail(
}
// 记录生成的缩略图信息
if let Ok(metadata) = std::fs::metadata(output_path) {
if let Ok(metadata) = std::fs::metadata(&thumbnail_full_path) {
log::info!(
"生成缩略图完成: {} (文件大小: {} bytes)",
output_path.display(),
thumbnail_full_path.display(),
metadata.len()
);
} else {
log::info!("生成缩略图完成: {}", output_path.display());
log::info!("生成缩略图完成: {}", thumbnail_full_path.display());
}
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)
Ok(thumbnail_full_path)
}
// 执行FFmpeg转换的通用函数
pub async fn execute_ffmpeg_conversion(
mut cmd: tokio::process::Command,
total_duration: f64,
reporter: &ProgressReporter,
mode_name: &str,
) -> Result<(), String> {
@@ -1049,20 +1029,7 @@ pub async fn execute_ffmpeg_conversion(
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));
}
reporter.update(&format!("正在转换视频格式... {} ({})", p.time, mode_name));
}
FfmpegEvent::LogEOF => break,
FfmpegEvent::Log(level, content) => {
@@ -1100,10 +1067,6 @@ pub async fn try_stream_copy_conversion(
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
// 获取视频时长以计算进度
let metadata = extract_video_metadata(source).await?;
let total_duration = metadata.duration;
reporter.update("正在转换视频格式... 0% (无损模式)");
// 构建ffmpeg命令 - 流复制模式
@@ -1128,7 +1091,7 @@ pub async fn try_stream_copy_conversion(
&dest.to_string_lossy(),
]);
execute_ffmpeg_conversion(cmd, total_duration, reporter, "无损转换").await
execute_ffmpeg_conversion(cmd, reporter, "无损转换").await
}
// 高质量重编码转换(兼容性好,质量高)
@@ -1137,10 +1100,6 @@ pub async fn try_high_quality_conversion(
dest: &Path,
reporter: &ProgressReporter,
) -> Result<(), String> {
// 获取视频时长以计算进度
let metadata = extract_video_metadata(source).await?;
let total_duration = metadata.duration;
reporter.update("正在转换视频格式... 0% (高质量模式)");
// 构建ffmpeg命令 - 高质量重编码
@@ -1171,7 +1130,7 @@ pub async fn try_high_quality_conversion(
&dest.to_string_lossy(),
]);
execute_ffmpeg_conversion(cmd, total_duration, reporter, "高质量转换").await
execute_ffmpeg_conversion(cmd, reporter, "高质量转换").await
}
// 带进度的视频格式转换函数(智能质量保持策略)
@@ -1205,4 +1164,14 @@ mod tests {
let resolution = get_video_resolution(file.to_str().unwrap()).await.unwrap();
assert_eq!(resolution, "1920x1080");
}
#[tokio::test]
async fn test_generate_thumbnail() {
let file = Path::new("tests/video/test.mp4");
let thumbnail_file = generate_thumbnail(file, 0.0).await.unwrap();
assert!(thumbnail_file.exists());
assert_eq!(thumbnail_file.extension().unwrap(), "jpg");
// clean up
std::fs::remove_file(thumbnail_file).unwrap();
}
}

View File

@@ -1,6 +1,6 @@
use crate::database::task::TaskRow;
use crate::database::video::{VideoNoCover, VideoRow};
use crate::ffmpeg;
use crate::ffmpeg::{self, generate_thumbnail};
use crate::handlers::utils::get_disk_info_inner;
use crate::progress_reporter::{
cancel_progress, EventEmitter, ProgressReporter, ProgressReporterTrait,
@@ -436,6 +436,7 @@ async fn clip_range_inner(
created_at: Local::now().to_rfc3339(),
cover: params.cover.clone(),
file: filename.into(),
note: params.note.clone(),
length: params.range.as_ref().map_or(0.0, |r| r.duration()) as i64,
size: metadata.len() as i64,
bvid: "".into(),
@@ -823,6 +824,15 @@ pub async fn update_video_subtitle(
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn update_video_note(state: state_type!(), id: i64, note: String) -> Result<(), String> {
log::info!("Update video note: {} -> {}", id, note);
let mut video = state.db.get_video(id).await?;
video.note = note;
state.db.update_video(&video).await?;
Ok(())
}
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn encode_video_subtitle(
state: state_type!(),
@@ -880,29 +890,11 @@ async fn encode_video_subtitle_inner(
let video = state.db.get_video(id).await?;
let config = state.config.read().await;
let filepath = Path::new(&config.output).join(&video.file);
let subtitle_path = filepath.with_extension("srt");
// 查找字幕文件:对于切片视频,需要查找原视频的字幕文件
let subtitle_path = find_subtitle_file(state, &video, &filepath).await?;
let output_file =
let output_filename =
ffmpeg::encode_video_subtitle(reporter, &filepath, &subtitle_path, srt_style).await?;
// 构建正确的相对路径:如果原文件在子目录中,保持相同的目录结构
let relative_output_file = if let Some((parent_dir, _)) = video.file.rsplit_once('/') {
// 原文件在子目录中(如 clips/xxx.mp4保持目录结构
format!("{}/{}", parent_dir, output_file)
} else {
// 原文件在根目录
output_file
};
// 为标题添加 [subtitle] 前缀
let subtitle_title = if video.title.starts_with("[subtitle]") {
video.title.clone() // 如果已经有前缀,不再添加
} else {
format!("[subtitle]{}", video.title)
};
let new_video = state
.db
.add_video(&VideoRow {
@@ -911,11 +903,12 @@ async fn encode_video_subtitle_inner(
room_id: video.room_id,
created_at: Local::now().to_rfc3339(),
cover: video.cover.clone(),
file: relative_output_file,
file: output_filename,
note: video.note.clone(),
length: video.length,
size: video.size,
bvid: video.bvid.clone(),
title: subtitle_title,
title: video.title.clone(),
desc: video.desc.clone(),
tags: video.tags.clone(),
area: video.area,
@@ -935,97 +928,74 @@ pub async fn generic_ffmpeg_command(
ffmpeg::generic_ffmpeg_command(&args_str).await
}
// 导入外部视频
#[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);
// 验证文件存在
if !source_path.exists() {
return Err("文件不存在".to_string());
}
// 步骤1: 获取视频元数据
reporter.update("正在提取视频元数据...");
let metadata = ffmpeg::extract_video_metadata(source_path).await?;
// 生成目标文件名
let config = state.config.read().await;
let output_dir = Path::new(&config.output).join("imported");
if !output_dir.exists() {
std::fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?;
}
let output_str = state.config.read().await.output.clone();
let output_dir = Path::new(&output_str);
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
let extension = source_path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("mp4");
let mut target_filename = format!(
"imported_{}_{}.{}",
timestamp,
"{}{}{}.{}",
crate::constants::PREFIX_IMPORTED,
sanitize_filename(&title),
timestamp,
extension
);
let target_path = output_dir.join(&target_filename);
let target_full_path = output_dir.join(&target_filename);
// 步骤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);
let final_target_full_path = if need_conversion {
let mp4_target_full_path = target_full_path.with_extension("mp4");
reporter.update("准备转换视频格式 (FLV → MP4)...");
// 使用智能转换函数,自动检测网络优化
copy_and_convert_with_progress(source_path, &mp4_target_path, true, &reporter).await?;
copy_and_convert_with_progress(source_path, &mp4_target_full_path, true, &reporter).await?;
// 更新最终文件名和路径
target_filename = mp4_filename;
mp4_target_path
target_filename = mp4_target_full_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string();
mp4_target_full_path
} else {
// 其他格式使用智能拷贝
copy_and_convert_with_progress(source_path, &target_path, false, &reporter).await?;
target_path
copy_and_convert_with_progress(source_path, &target_full_path, false, &reporter).await?;
target_full_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",
final_target_path.file_stem().unwrap().to_str().unwrap()
);
let thumbnail_path = thumbnail_dir.join(&thumbnail_filename);
// 生成缩略图,使用智能时间点选择
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),
match ffmpeg::generate_thumbnail(&final_target_full_path, thumbnail_timestamp).await {
Ok(path) => path.file_name().unwrap().to_str().unwrap().to_string(),
Err(e) => {
log::warn!("生成缩略图失败: {}", e);
"".to_string() // 使用空字符串,前端会显示默认图标
@@ -1035,32 +1005,23 @@ pub async fn import_external_video(
// 步骤4: 保存到数据库
reporter.update("正在保存视频信息...");
// 构建导入视频的元数据
let import_metadata = ImportedVideoMetadata {
original_path: file_path.clone(),
import_date: Utc::now().to_rfc3339(),
original_size: size,
video_format: extension.to_string(),
duration: metadata.duration,
resolution: Some(format!("{}x{}", metadata.width, metadata.height)),
};
// 添加到数据库
let video = VideoRow {
id: 0,
room_id, // 使用传入的 room_id
platform: "imported".to_string(), // 使用 platform 字段标识
room_id, // 使用传入的 room_id
platform: "imported".to_string(),
title,
file: format!("imported/{}", target_filename), // 包含完整相对路径
file: target_filename,
note: "".to_string(),
length: metadata.duration as i64,
size: final_target_path
size: final_target_full_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(),
tags: "imported,external".to_string(),
desc: "".to_string(),
tags: "".to_string(),
bvid: "".to_string(),
area: 0,
created_at: Utc::now().to_rfc3339(),
@@ -1094,11 +1055,6 @@ pub async fn clip_video(
// 获取父视频信息
let parent_video = state.db.get_video(parent_video_id).await?;
// 检查是否为正在录制的视频
if parent_video.status == -1 {
return Err("正在录制的视频无法进行切片".to_string());
}
#[cfg(feature = "gui")]
let emitter = EventEmitter::new(state.app_handle.clone());
#[cfg(feature = "headless")]
@@ -1186,76 +1142,62 @@ async fn clip_video_inner(
.and_then(|name| name.to_str())
.unwrap_or("video");
// 生成新的文件名格式:原文件名[clip][时间戳].扩展名
let output_filename = format!("{}[clip][{}].{}", original_filename, timestamp, extension);
let output_path = output_dir.join(&output_filename);
// 生成新的文件名格式:[clip]原文件名[时间戳].扩展名
let output_filename = format!(
"{}{}[{}].{}",
crate::constants::PREFIX_CLIP,
original_filename,
timestamp,
extension
);
let output_full_path = output_dir.join(&output_filename);
// 执行切片
reporter.update("开始切片处理");
ffmpeg::clip_from_video_file(
Some(reporter),
&input_path,
&output_path,
&output_full_path,
start_time,
end_time - start_time,
)
.await?;
// 生成缩略图
let thumbnail_dir = Path::new(&config.output).join("thumbnails").join("clips");
if !thumbnail_dir.exists() {
std::fs::create_dir_all(&thumbnail_dir).map_err(|e| e.to_string())?;
}
// 生成缩略图文件名,确保路径安全
let clip_thumbnail_filename =
if let Some(stem) = output_path.file_stem().and_then(|s| s.to_str()) {
format!("{}.jpg", stem)
} else {
format!("thumbnail_{}.jpg", timestamp)
};
let thumbnail_path = thumbnail_dir.join(&clip_thumbnail_filename);
let thumbnail_full_path = output_full_path.with_extension("jpg");
// 生成缩略图,选择切片开头的合理位置
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),
match ffmpeg::generate_thumbnail(&output_full_path, clip_thumbnail_timestamp).await {
Ok(_) => thumbnail_full_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
Err(e) => {
log::warn!("生成切片缩略图失败: {}", e);
"".to_string() // 使用空字符串,前端会显示默认图标
}
};
// 构建统一的切片元数据
let clip_metadata = ClipMetadata {
parent_video_id: parent_video.id,
start_time,
end_time,
clip_source: determine_clip_source(&parent_video.platform),
original_platform: parent_video.platform.clone(),
original_room_id: parent_video.room_id,
};
let file_metadata = output_full_path.metadata().map_err(|e| e.to_string())?;
// 获取输出文件信息
let file_metadata = output_path.metadata().map_err(|e| e.to_string())?;
// 添加到数据库 - 统一使用 "clip" 平台类型
let clip_video = VideoRow {
id: 0,
room_id: parent_video.room_id,
platform: "clip".to_string(), // 统一的切片类型
platform: "clip".to_string(),
title: clip_title,
file: format!("clips/{}", output_filename),
file: output_filename,
note: "".to_string(),
length: (end_time - start_time) as i64,
size: file_metadata.len() as i64,
status: 1,
cover: clip_cover_path,
desc: serde_json::to_string(&clip_metadata).unwrap_or_default(),
tags: "clip".to_string(),
desc: "".to_string(),
tags: "".to_string(),
bvid: "".to_string(),
area: parent_video.area,
created_at: Local::now().to_rfc3339(),
@@ -1272,46 +1214,6 @@ async fn clip_video_inner(
Ok(result)
}
// 确定切片来源的辅助函数
fn determine_clip_source(platform: &str) -> String {
match platform {
"imported" => "imported_video".to_string(),
"clip" => "clip".to_string(),
_ => "recorded_video".to_string(),
}
}
// 查找字幕文件的辅助函数
async fn find_subtitle_file(
state: &state_type!(),
video: &VideoRow,
video_file: &Path,
) -> Result<PathBuf, String> {
// 首先尝试当前视频同目录下的字幕文件
let local_subtitle = video_file.with_extension("srt");
if local_subtitle.exists() {
return Ok(local_subtitle);
}
// 如果是切片视频,尝试查找原视频的字幕文件
if video.platform == "clip" && !video.desc.is_empty() {
// 解析切片元数据获取父视频ID
if let Ok(metadata) = serde_json::from_str::<ClipMetadata>(&video.desc) {
if let Ok(parent_video) = state.db.get_video(metadata.parent_video_id).await {
let parent_filepath =
Path::new(&state.config.read().await.output).join(&parent_video.file);
let parent_subtitle = parent_filepath.with_extension("srt");
if parent_subtitle.exists() {
return Ok(parent_subtitle);
}
}
}
}
// 如果都找不到返回默认路径即使文件不存在让ffmpeg处理错误
Ok(local_subtitle)
}
// 获取文件大小
#[cfg_attr(feature = "gui", tauri::command)]
pub async fn get_file_size(file_path: String) -> Result<u64, String> {
@@ -1350,69 +1252,11 @@ fn cleanup_source_flv_file(file_path: &Path, cleanup_enabled: bool) {
}
}
/// 为导入的视频生成缩略图
///
/// # 参数
/// - `video_file`: 视频文件路径
/// - `file_name`: 文件名(用于生成缩略图文件名)
/// - `output_path`: 输出目录路径
/// - `duration`: 视频时长(用于选择最佳截图时间点)
///
/// # 返回值
/// 成功时返回缩略图的相对路径,失败时返回空字符串
async fn generate_imported_video_thumbnail(
video_file: &Path,
file_name: &str,
output_path: &str,
duration: f64,
) -> Result<String, String> {
let timestamp = chrono::Utc::now().timestamp();
let thumbnail_name = format!(
"imported_{}_{}.jpg",
timestamp,
sanitize_filename(file_name)
);
let thumbnail_dir = Path::new(output_path).join("thumbnails").join("imported");
let thumbnail_path = thumbnail_dir.join(&thumbnail_name);
// 确保缩略图目录存在
if !thumbnail_dir.exists() {
std::fs::create_dir_all(&thumbnail_dir)
.map_err(|e| format!("创建缩略图目录失败: {}", e))?;
}
// 获取最佳缩略图时间点
let thumbnail_time = get_optimal_thumbnail_timestamp(duration);
// 生成缩略图
match ffmpeg::generate_thumbnail(video_file, &thumbnail_path, thumbnail_time).await {
Ok(_) => Ok(format!("thumbnails/imported/{}", thumbnail_name)),
Err(e) => {
log::warn!("生成缩略图失败,使用默认封面: {}", e);
Ok("".to_string())
}
}
}
/// 转换完成后创建数据库记录
///
/// 用于异步转换任务完成后,创建对应的视频数据库记录
///
/// # 参数
/// - `db`: 数据库连接
/// - `final_file_path`: 最终视频文件路径MP4
/// - `file_name`: 原始文件名
/// - `room_id`: 房间ID
/// - `output_path`: 输出目录路径
///
/// # 返回值
/// 成功时返回视频ID失败时返回错误信息
async fn create_video_record_after_conversion(
db: &crate::database::Database,
final_file_path: &Path,
file_name: &str,
room_id: u64,
output_path: &str,
) -> Result<i64, String> {
// 获取视频元数据基于转换后的MP4文件
let video_metadata = ffmpeg::extract_video_metadata(final_file_path)
@@ -1420,46 +1264,21 @@ async fn create_video_record_after_conversion(
.map_err(|e| format!("获取视频元数据失败: {}", e))?;
// 生成缩略图
let cover_path = generate_imported_video_thumbnail(
final_file_path,
file_name,
output_path,
video_metadata.duration,
)
.await?;
// 创建导入元数据
let import_metadata = ImportedVideoMetadata {
original_path: final_file_path.to_string_lossy().to_string(),
original_size: final_file_path
.metadata()
.map_err(|e| format!("获取文件大小失败: {}", e))?
.len() as i64,
import_date: chrono::Utc::now().to_rfc3339(),
video_format: "mp4".to_string(), // 转换后都是MP4
duration: video_metadata.duration,
resolution: Some(format!(
"{}x{}",
video_metadata.width, video_metadata.height
)),
};
let metadata_json =
serde_json::to_string(&import_metadata).map_err(|e| format!("序列化元数据失败: {}", e))?;
// 获取文件的相对路径
let relative_path = final_file_path
.strip_prefix(output_path)
.map_err(|e| format!("计算相对路径失败: {}", e))?
.to_string_lossy()
.replace('\\', "/"); // 统一使用Unix风格路径分隔符
let thumbnail_timestamp = get_optimal_thumbnail_timestamp(video_metadata.duration);
let cover_full_path = generate_thumbnail(final_file_path, thumbnail_timestamp).await?;
// 创建视频记录
let video_row = VideoRow {
id: 0, // 将由数据库分配
room_id,
cover: cover_path,
file: relative_path,
cover: cover_full_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
file: file_name.to_string(),
note: "".to_string(),
length: video_metadata.duration as i64,
size: final_file_path
.metadata()
@@ -1468,7 +1287,7 @@ async fn create_video_record_after_conversion(
status: 0,
bvid: "".to_string(),
title: file_name.to_string(),
desc: metadata_json,
desc: "".to_string(),
tags: "".to_string(),
area: 0,
created_at: chrono::Utc::now().to_rfc3339(),
@@ -1704,10 +1523,6 @@ pub async fn import_file_in_place(
// 获取配置和其他数据用于后续处理
let cleanup_source_flv = state.config.read().await.cleanup_source_flv_after_import;
let output_path = {
let config = state.config.read().await;
config.output.clone()
};
let room_id_for_task = room_id;
let file_name_for_task = file_name.clone();
@@ -1737,7 +1552,6 @@ pub async fn import_file_in_place(
&target_path,
&file_name_for_task,
room_id_for_task,
&output_path,
)
.await
{
@@ -1801,13 +1615,8 @@ pub async fn import_file_in_place(
.map_err(|e| format!("获取视频元数据失败: {}", e))?;
// 生成缩略图
let cover_path = generate_imported_video_thumbnail(
&final_file_path,
&file_name,
&output_path,
video_metadata.duration,
)
.await?;
let cover_path =
ffmpeg::generate_thumbnail(&final_file_path, video_metadata.duration / 2.0).await?;
// 创建导入元数据
let import_metadata = ImportedVideoMetadata {
@@ -1839,8 +1648,14 @@ pub async fn import_file_in_place(
let video_row = VideoRow {
id: 0, // 将由数据库分配
room_id,
cover: cover_path,
cover: cover_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
file: final_relative_path,
note: "".to_string(),
length: video_metadata.duration as i64,
size: final_file_size,
status: 0,
@@ -1979,20 +1794,12 @@ pub async fn batch_import_external_videos(
// 从文件名生成标题(去掉扩展名)
let title = file_name.clone();
// 获取文件大小
let size = match get_file_size(file_path.clone()).await {
Ok(s) => s as i64,
Err(_) => 0,
};
// 调用现有的单文件导入函数
match import_external_video(
state.clone(),
file_event_id,
file_path.clone(),
title,
file_name.clone(),
size,
room_id,
)
.await

View File

@@ -35,7 +35,8 @@ use crate::{
delete_video, encode_video_subtitle, generate_video_subtitle, generic_ffmpeg_command,
get_all_videos, get_file_size, get_import_progress, get_video, get_video_cover,
get_video_subtitle, get_video_typelist, get_videos, import_external_video,
scan_imported_directory, update_video_cover, update_video_subtitle, upload_procedure,
scan_imported_directory, update_video_cover, update_video_note, update_video_subtitle,
upload_procedure,
},
AccountInfo,
},
@@ -937,6 +938,21 @@ async fn handler_update_video_subtitle(
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateVideoNoteRequest {
id: i64,
note: String,
}
async fn handler_update_video_note(
state: axum::extract::State<State>,
Json(param): Json<UpdateVideoNoteRequest>,
) -> Result<Json<ApiResponse<()>>, ApiError> {
update_video_note(state.0, param.id, param.note).await?;
Ok(Json(ApiResponse::success(())))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EncodeVideoSubtitleRequest {
@@ -967,8 +983,6 @@ struct ImportExternalVideoRequest {
event_id: String,
file_path: String,
title: String,
original_name: String,
size: i64,
room_id: u64,
}
@@ -981,8 +995,6 @@ async fn handler_import_external_video(
param.event_id.clone(),
param.file_path.clone(),
param.title,
param.original_name,
param.size,
param.room_id,
)
.await?;
@@ -1907,6 +1919,7 @@ pub async fn start_api_server(state: State) {
post(handler_update_video_subtitle),
)
.route("/api/update_video_cover", post(handler_update_video_cover))
.route("/api/update_video_note", post(handler_update_video_note))
.route(
"/api/encode_video_subtitle",
post(handler_encode_video_subtitle),

View File

@@ -3,6 +3,7 @@
mod archive_migration;
mod config;
mod constants;
mod danmu2ass;
mod database;
mod ffmpeg;
@@ -184,6 +185,13 @@ fn get_migrations() -> Vec<Migration> {
"#,
kind: MigrationKind::Up,
},
// add note column for video
Migration {
version: 8,
description: "add_note_column_for_video",
sql: r#"ALTER TABLE videos ADD COLUMN note TEXT;"#,
kind: MigrationKind::Up,
},
]
}
@@ -560,6 +568,7 @@ fn setup_invoke_handlers(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<
crate::handlers::video::generate_video_subtitle,
crate::handlers::video::get_video_subtitle,
crate::handlers::video::update_video_subtitle,
crate::handlers::video::update_video_note,
crate::handlers::video::encode_video_subtitle,
crate::handlers::video::generic_ffmpeg_command,
crate::handlers::video::import_external_video,

View File

@@ -35,6 +35,7 @@ pub struct RecorderList {
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ClipRangeParams {
pub title: String,
pub note: String,
pub cover: String,
pub platform: String,
pub room_id: u64,
@@ -198,6 +199,7 @@ impl RecorderManager {
let clip_config = ClipRangeParams {
title: live_record.title,
note: "".into(),
cover: "".into(),
platform: live_record.platform.clone(),
room_id,
@@ -238,6 +240,7 @@ impl RecorderManager {
created_at: Utc::now().to_rfc3339(),
cover: "".into(),
file: f.file_name().unwrap().to_str().unwrap().to_string(),
note: "".into(),
length: live_record.length,
size: metadata.len() as i64,
bvid: "".into(),

Binary file not shown.

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Room from "./page/Room.svelte";
import BSidebar from "./lib/BSidebar.svelte";
import BSidebar from "./lib/components/BSidebar.svelte";
import Summary from "./page/Summary.svelte";
import Setting from "./page/Setting.svelte";
import Account from "./page/Account.svelte";

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { invoke, convertFileSrc, convertCoverSrc } from "./lib/invoker";
import { onMount } from "svelte";
import VideoPreview from "./lib/VideoPreview.svelte";
import VideoPreview from "./lib/components/VideoPreview.svelte";
import type { Config, VideoItem } from "./lib/interface";
import { set_title } from "./lib/invoker";

View File

@@ -8,7 +8,7 @@
listen,
log,
} from "./lib/invoker";
import Player from "./lib/Player.svelte";
import Player from "./lib/components/Player.svelte";
import type { RecordItem } from "./lib/db";
import { ChevronRight, ChevronLeft, Play, Pen } from "lucide-svelte";
import {
@@ -21,7 +21,7 @@
clipRange,
generateEventId,
} from "./lib/interface";
import MarkerPanel from "./lib/MarkerPanel.svelte";
import MarkerPanel from "./lib/components/MarkerPanel.svelte";
import { onDestroy, onMount } from "svelte";
const urlParams = new URLSearchParams(window.location.search);
@@ -42,6 +42,7 @@
let current_clip_event_id = null;
let danmu_enabled = false;
let fix_encoding = false;
let clip_note: string = "";
// 弹幕相关变量
let danmu_records: DanmuEntry[] = [];
@@ -66,11 +67,11 @@
visible_start_index = Math.max(
0,
Math.floor(scroll_top / danmu_item_height) - buffer
Math.floor(scroll_top / danmu_item_height) - buffer,
);
visible_end_index = Math.min(
filtered_danmu.length,
Math.ceil((scroll_top + container_height) / danmu_item_height) + buffer
Math.ceil((scroll_top + container_height) / danmu_item_height) + buffer,
);
}
@@ -158,9 +159,9 @@
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts = [] as string[];
if (hours > 0) parts.push(`${hours}小时`);
if (minutes > 0) parts.push(`${minutes}分`);
parts.push(`${seconds}秒`);
if (hours > 0) parts.push(`${hours} 小时`);
if (minutes > 0) parts.push(`${minutes} 分`);
parts.push(`${seconds} 秒`);
return parts.join(" ");
}
@@ -189,7 +190,7 @@
}
current_clip_event_id = null;
}
}
},
);
onDestroy(() => {
@@ -216,8 +217,6 @@
end = parseFloat(localStorage.getItem(`${live_id}_end`)) - focus_start;
}
function generateCover() {
const video = document.getElementById("video") as HTMLVideoElement;
var w = video.videoWidth;
@@ -267,7 +266,7 @@
(a: RecordItem) => {
archive = a;
set_title(`[${room_id}]${archive.title}`);
}
},
);
function update_clip_prompt(str: string) {
@@ -279,16 +278,20 @@
}
async function get_video_list() {
const videoList = (await invoke("get_videos", { roomId: room_id })) as VideoItem[];
videos = await Promise.all(videoList.map(async (v) => {
return {
id: v.id,
value: v.id,
name: v.file,
file: await convertFileSrc(v.file),
cover: v.cover,
};
}));
const videoList = (await invoke("get_videos", {
roomId: room_id,
})) as VideoItem[];
videos = await Promise.all(
videoList.map(async (v) => {
return {
id: v.id,
value: v.id,
name: v.file,
file: await convertFileSrc(v.file),
cover: v.cover,
};
}),
);
}
async function find_video(e) {
@@ -301,7 +304,7 @@
return v.value == id;
});
if (target_video) {
const rawCover = await invoke("get_video_cover", { id: id }) as string;
const rawCover = (await invoke("get_video_cover", { id: id })) as string;
target_video.cover = await convertCoverSrc(rawCover, id);
}
selected_video = target_video;
@@ -328,6 +331,7 @@
current_clip_event_id = event_id;
let new_video = (await clipRange(event_id, {
title: archive.title,
note: clip_note,
room_id: room_id,
platform: platform,
cover: new_cover,
@@ -350,6 +354,9 @@
if (selected_video) {
selected_video.cover = new_video.cover;
}
// clean up previous input data
clip_note = "";
}
async function cancel_clip() {
@@ -374,13 +381,13 @@
let markers: Marker[] = [];
// load markers from local storage
markers = JSON.parse(
window.localStorage.getItem(`markers:${room_id}:${live_id}`) || "[]"
window.localStorage.getItem(`markers:${room_id}:${live_id}`) || "[]",
);
$: {
// makers changed, save to local storage
window.localStorage.setItem(
`markers:${room_id}:${live_id}`,
JSON.stringify(markers)
JSON.stringify(markers),
);
}
@@ -710,8 +717,8 @@
<h3 class="text-[17px] font-semibold text-white">确认生成切片</h3>
<p class="mt-1 text-[13px] text-white/70">请确认以下设置后继续</p>
<div class="mt-4 rounded-xl bg-[#2c2c2e] border border-white/10 p-3">
<div class="text-[13px] text-white/80">切片时长</div>
<div class="mt-3 space-y-3">
<div class="text-[13px] text-white/80">> 切片时长</div>
<div
class="mt-0.5 text-[22px] font-semibold tracking-tight text-white"
>
@@ -719,6 +726,19 @@
</div>
</div>
<div class="mt-3 space-y-3">
<div class="mt-1 text-[13px] text-white/80">> 切片备注(可选)</div>
<input
type="text"
id="confirm-clip-note-input"
bind:value={clip_note}
class="w-full px-3 py-2 bg-[#2c2c2e] text-white rounded-lg
border border-gray-800/50 focus:border-[#0A84FF]
transition duration-200 outline-none
placeholder-gray-500"
/>
</div>
<div class="mt-3 space-y-3">
<label class="flex items-center gap-2.5">
<input

View File

@@ -1,607 +0,0 @@
<script lang="ts">
import { invoke, TAURI_ENV, ENDPOINT, listen, onConnectionRestore } from "../lib/invoker";
import { Upload, X, CheckCircle } from "lucide-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;
const dispatch = createEventDispatcher();
let selectedFilePath: string | null = null;
let selectedFileName: string = "";
let selectedFileSize: number = 0;
let videoTitle = "";
let importing = false;
let uploading = false;
let uploadProgress = 0;
let dragOver = false;
let fileInput: HTMLInputElement;
let importProgress = "";
let currentImportEventId: string | null = null;
// 批量导入状态
let selectedFiles: string[] = [];
let batchImporting = false;
let currentFileIndex = 0;
let totalFiles = 0;
// 获取当前正在处理的文件名(从文件路径中提取文件名)
$: currentFileName = currentFileIndex > 0 && selectedFiles.length > 0 ?
selectedFiles[currentFileIndex - 1]?.split(/[/\\]/).pop() || '未知文件' :
'';
// 格式化文件大小
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 match = importProgress.match(/正在导入第(\d+)个/);
if (match) {
currentFileIndex = parseInt(match[1]);
}
}
});
const progressFinishedListener = listen<ProgressFinished>('progress-finished', (e) => {
if (e.payload.id === currentImportEventId) {
if (e.payload.success) {
// 导入成功,关闭对话框并刷新列表
showDialog = false;
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
resetBatchImportState();
dispatch("imported");
} else {
alert("导入失败: " + e.payload.message);
resetBatchImportState();
}
// 无论成功失败都要重置状态
importing = false;
currentImportEventId = null;
importProgress = "";
}
});
// 连接恢复时检查任务状态
async function checkTaskStatus() {
if (!currentImportEventId || !importing) return;
try {
const progress = await invoke("get_import_progress");
if (!progress) {
importing = false;
currentImportEventId = null;
importProgress = "";
resetBatchImportState();
dispatch("imported");
}
} catch (error) {
console.error(`[ImportDialog] Failed to check task status:`, error);
}
}
// 注册连接恢复回调
if (!TAURI_ENV) {
onConnectionRestore(checkTaskStatus);
}
onDestroy(() => {
progressUpdateListener?.then(fn => fn());
progressFinishedListener?.then(fn => fn());
});
async function handleFileSelect() {
if (TAURI_ENV) {
// Tauri模式使用文件对话框支持多选
try {
const selected = await open({
multiple: true,
filters: [{
name: '视频文件',
extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'm4v', 'webm']
}]
});
// 检查用户是否取消了选择
if (!selected) return;
if (Array.isArray(selected) && selected.length > 1) {
// 批量导入:多个文件
selectedFiles = selected;
await startBatchImport();
} else if (Array.isArray(selected) && selected.length === 1) {
// 单文件导入:数组中的单个文件
await setSelectedFile(selected[0]);
} else if (typeof selected === 'string') {
// 单文件导入:直接返回字符串路径
await setSelectedFile(selected);
}
} catch (error) {
console.error("文件选择失败:", error);
alert("文件选择失败: " + error);
}
} else {
// Web模式触发文件输入
fileInput?.click();
}
}
async function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
if (files.length > 1) {
// 批量上传模式
await uploadAndImportMultipleFiles(Array.from(files));
} else {
// 单文件上传模式(保持现有逻辑)
const file = files[0];
// 提前设置文件信息,提升用户体验
selectedFileName = file.name;
videoTitle = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
selectedFileSize = file.size;
await uploadFile(file);
}
}
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (TAURI_ENV) return; // Tauri模式不支持拖拽
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
// 检查文件类型
const allowedTypes = ['video/mp4', 'video/x-msvideo', 'video/quicktime', 'video/x-ms-wmv', 'video/x-flv', 'video/x-m4v', 'video/webm', 'video/x-matroska'];
if (allowedTypes.includes(file.type) || file.name.match(/\.(mp4|mkv|avi|mov|wmv|flv|m4v|webm)$/i)) {
// 提前设置文件信息,提升用户体验
selectedFileName = file.name;
videoTitle = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
selectedFileSize = file.size;
await uploadFile(file);
} else {
alert("请选择支持的视频文件格式 (MP4, MKV, AVI, MOV, WMV, FLV, M4V, WebM)");
}
}
}
async function uploadFile(file: File) {
uploading = true;
uploadProgress = 0;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('roomId', String(roomId || 0));
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
// 处理上传完成
xhr.addEventListener('load', async () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.code === 0 && response.data) {
// 使用本地文件信息,更快更准确
await setSelectedFile(response.data.filePath, file.size);
} else {
throw new Error(response.message || '上传失败');
}
} else {
throw new Error(`上传失败: HTTP ${xhr.status}`);
}
uploading = false;
});
xhr.addEventListener('error', () => {
alert("上传失败:网络错误");
uploading = false;
});
xhr.open('POST', `${ENDPOINT}/api/upload_file`);
xhr.send(formData);
} catch (error) {
console.error("上传失败:", error);
alert("上传失败: " + error);
uploading = false;
}
}
async function uploadAndImportMultipleFiles(files: File[]) {
batchImporting = true;
importing = true;
totalFiles = files.length;
currentFileIndex = 0;
importProgress = `准备批量上传和导入 ${totalFiles} 个文件...`;
// 设置当前处理的文件名列表
const fileNames = files.map(file => file.name);
try {
// 验证所有文件格式
const allowedTypes = ['video/mp4', 'video/x-msvideo', 'video/quicktime', 'video/x-ms-wmv', 'video/x-flv', 'video/x-m4v', 'video/webm', 'video/x-matroska'];
for (const file of files) {
if (!allowedTypes.includes(file.type) && !file.name.match(/\.(mp4|mkv|avi|mov|wmv|flv|m4v|webm)$/i)) {
throw new Error(`不支持的文件格式: ${file.name}`);
}
}
const formData = new FormData();
formData.append('room_id', String(roomId || 0));
files.forEach((file) => {
formData.append('files', file);
});
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
importProgress = `批量上传进度: ${progress}%`;
// 根据进度估算当前正在上传的文件
const estimatedCurrentIndex = Math.min(
Math.floor((progress / 100) * totalFiles),
totalFiles - 1
);
currentFileName = fileNames[estimatedCurrentIndex] || fileNames[0];
}
});
// 处理上传完成
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.code === 0) {
// 批量上传和导入成功,关闭对话框并刷新列表
showDialog = false;
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
resetBatchImportState();
dispatch("imported");
} else {
throw new Error(response.message || '批量导入失败');
}
} else {
throw new Error(`批量上传失败: HTTP ${xhr.status}`);
}
});
xhr.addEventListener('error', () => {
alert("批量上传失败:网络错误");
resetBatchImportState();
});
xhr.open('POST', `${ENDPOINT}/api/upload_and_import_files`);
xhr.send(formData);
} catch (error) {
console.error("批量上传失败:", error);
alert("批量上传失败: " + error);
resetBatchImportState();
}
}
async function setSelectedFile(filePath: string, fileSize?: number) {
selectedFilePath = filePath;
selectedFileName = filePath.split(/[/\\]/).pop() || '';
videoTitle = selectedFileName.replace(/\.[^/.]+$/, ""); // 去掉扩展名
if (fileSize !== undefined) {
selectedFileSize = fileSize;
} else {
// 获取文件大小 (Tauri模式)
try {
selectedFileSize = await invoke("get_file_size", { filePath });
} catch (e) {
selectedFileSize = 0;
}
}
}
/**
* 开始批量导入视频文件
*/
async function startBatchImport() {
if (selectedFiles.length === 0) return;
batchImporting = true;
importing = true;
totalFiles = selectedFiles.length;
currentFileIndex = 0;
importProgress = `准备批量导入 ${totalFiles} 个文件...`;
try {
const eventId = "batch_import_" + Date.now();
currentImportEventId = eventId;
await invoke("batch_import_external_videos", {
eventId: eventId,
filePaths: selectedFiles,
roomId: roomId || 0
});
// 注意:成功处理在 progressFinishedListener 中进行
} catch (error) {
console.error("批量导入失败:", error);
alert("批量导入失败: " + error);
resetBatchImportState();
}
}
/**
* 重置批量导入状态
*/
function resetBatchImportState() {
batchImporting = false;
importing = false;
currentImportEventId = null;
importProgress = "";
selectedFiles = [];
totalFiles = 0;
currentFileIndex = 0;
}
async function startImport() {
if (!selectedFilePath) return;
importing = true;
importProgress = "准备导入...";
try {
const eventId = "import_" + Date.now();
currentImportEventId = eventId;
await invoke("import_external_video", {
eventId: eventId,
filePath: selectedFilePath,
title: videoTitle,
originalName: selectedFileName,
size: selectedFileSize,
roomId: roomId || 0
});
// 注意成功处理移到了progressFinishedListener中
} catch (error) {
console.error("导入失败:", error);
alert("导入失败: " + error);
importing = false;
currentImportEventId = null;
importProgress = "";
}
}
/**
* 关闭对话框并重置所有状态
*/
function closeDialog() {
showDialog = false;
// 重置单文件导入状态
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
uploading = false;
uploadProgress = 0;
importing = false;
currentImportEventId = null;
importProgress = "";
// 重置批量导入状态
resetBatchImportState();
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
if (!TAURI_ENV) {
dragOver = true;
}
}
function handleDragLeave() {
dragOver = false;
}
</script>
<!-- 隐藏的文件输入 -->
{#if !TAURI_ENV}
<input
bind:this={fileInput}
type="file"
accept=".mp4,.mkv,.avi,.mov,.wmv,.flv,.m4v,.webm,video/*"
multiple
style="display: none"
on:change={handleFileInputChange}
/>
{/if}
{#if showDialog}
<div class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-[#323234] rounded-xl shadow-xl w-full max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col">
<div class="flex-1 overflow-y-auto">
<div class="p-6 space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">导入外部视频</h3>
<button on:click={closeDialog} class="text-gray-400 hover:text-gray-600">
<X class="w-5 h-5" />
</button>
</div>
<!-- 文件选择区域 -->
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors {
dragOver ? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20' :
'border-gray-300 dark:border-gray-600'
}"
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
>
{#if uploading}
<!-- 上传进度 -->
<div class="space-y-4">
<Upload class="w-12 h-12 text-blue-500 mx-auto animate-bounce" />
<p class="text-sm text-gray-900 dark:text-white font-medium">上传中...</p>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-blue-500 h-2 rounded-full transition-all" style="width: {uploadProgress}%"></div>
</div>
<p class="text-xs text-gray-500">{uploadProgress}%</p>
</div>
{:else if batchImporting}
<!-- 批量导入中 -->
<div class="space-y-4">
<div class="flex items-center justify-center">
<svg class="animate-spin h-12 w-12 text-blue-500" 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>
</div>
<p class="text-sm text-gray-900 dark:text-white font-medium">批量导入进行中...</p>
<div class="text-xs text-gray-500">{importProgress}</div>
{#if currentFileName}
<div class="text-xs text-gray-400 break-all">
当前文件:{currentFileName}
</div>
{/if}
</div>
{:else if selectedFilePath}
<!-- 已选择文件 -->
<div class="space-y-4">
<div class="flex items-center justify-center">
<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">大小: {formatFileSize(selectedFileSize)}</p>
<p class="text-xs text-gray-400 break-all" title={selectedFilePath}>{selectedFilePath}</p>
<button
on:click={() => {
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
}}
class="text-sm text-red-500 hover:text-red-700"
>
重新选择
</button>
</div>
{:else}
<!-- 选择文件提示 -->
<div class="space-y-4">
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
{#if TAURI_ENV}
<p class="text-sm text-gray-600 dark:text-gray-400">
点击按钮选择视频文件(支持多选)
</p>
{:else}
<p class="text-sm text-gray-600 dark:text-gray-400">
拖拽视频文件到此处,或点击按钮选择文件(支持多选)
</p>
{/if}
<p class="text-xs text-gray-500 dark:text-gray-500">
支持 MP4, MKV, AVI, MOV, WMV, FLV, M4V, WebM 格式
</p>
</div>
{/if}
{#if !uploading && !selectedFilePath && !batchImporting}
<button
on:click={handleFileSelect}
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
{TAURI_ENV ? '选择文件' : '选择或拖拽文件'}
</button>
{/if}
</div>
<!-- 视频信息编辑 -->
{#if selectedFilePath}
<div class="space-y-4">
<div>
<label for="video-title-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
视频标题
</label>
<input
id="video-title-input"
type="text"
bind:value={videoTitle}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
placeholder="输入视频标题"
/>
</div>
</div>
{/if}
</div>
</div>
<!-- 操作按钮 - 固定在底部 -->
<div class="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-[#2a2a2c]">
<div class="flex justify-end space-x-3">
<button
on:click={closeDialog}
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
取消
</button>
<button
on:click={startImport}
disabled={!selectedFilePath || importing || !videoTitle.trim() || uploading || batchImporting}
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"
>
{#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>
</div>
</div>
{/if}

View File

@@ -621,6 +621,7 @@ const clip_range = tool(
"The offset for danmu timestamp, it is used to correct the timestamp of danmu",
),
title: z.string().describe("The title of the clip"),
note: z.string().describe("The note of the clip"),
cover: z.string().describe("Must be empty"),
platform: z.string().describe("The platform of the clip"),
fix_encoding: z

View File

@@ -9,7 +9,7 @@
Video,
Brain,
} from "lucide-svelte";
import { hasNewVersion } from "./stores/version";
import { hasNewVersion } from "../stores/version";
import SidebarItem from "./SidebarItem.svelte";
import { createEventDispatcher } from "svelte";

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { Play, X, Type, Palette, Move, Plus, Trash2 } from "lucide-svelte";
import { invoke, log } from "../lib/invoker";
import { invoke, log } from "../invoker";
import { onMount, createEventDispatcher } from "svelte";
const dispatch = createEventDispatcher();

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { get, log } from "./invoker";
import { get, log } from "../invoker";
export let src = "";
export let iclass = "";
let b = "";

View File

@@ -0,0 +1,722 @@
<script lang="ts">
import {
invoke,
TAURI_ENV,
ENDPOINT,
listen,
onConnectionRestore,
} from "../invoker";
import { Upload, X, CheckCircle } from "lucide-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;
const dispatch = createEventDispatcher();
let selectedFilePath: string | null = null;
let selectedFileName: string = "";
let selectedFileSize: number = 0;
let videoTitle = "";
let importing = false;
let uploading = false;
let uploadProgress = 0;
let dragOver = false;
let fileInput: HTMLInputElement;
let importProgress = "";
let currentImportEventId: string | null = null;
// 批量导入状态
let selectedFiles: string[] = [];
let batchImporting = false;
let currentFileIndex = 0;
let totalFiles = 0;
// 获取当前正在处理的文件名(从文件路径中提取文件名)
$: currentFileName =
currentFileIndex > 0 && selectedFiles.length > 0
? selectedFiles[currentFileIndex - 1]?.split(/[/\\]/).pop() || "未知文件"
: "";
// 格式化文件大小
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 match = importProgress.match(/正在导入第(\d+)个/);
if (match) {
currentFileIndex = parseInt(match[1]);
}
}
}
);
const progressFinishedListener = listen<ProgressFinished>(
"progress-finished",
(e) => {
if (e.payload.id === currentImportEventId) {
if (e.payload.success) {
// 导入成功,关闭对话框并刷新列表
showDialog = false;
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
resetBatchImportState();
dispatch("imported");
} else {
alert("导入失败: " + e.payload.message);
resetBatchImportState();
}
// 无论成功失败都要重置状态
importing = false;
currentImportEventId = null;
importProgress = "";
}
}
);
// 连接恢复时检查任务状态
async function checkTaskStatus() {
if (!currentImportEventId || !importing) return;
try {
const progress = await invoke("get_import_progress");
if (!progress) {
importing = false;
currentImportEventId = null;
importProgress = "";
resetBatchImportState();
dispatch("imported");
}
} catch (error) {
console.error(`[ImportDialog] Failed to check task status:`, error);
}
}
// 注册连接恢复回调
if (!TAURI_ENV) {
onConnectionRestore(checkTaskStatus);
}
onDestroy(() => {
progressUpdateListener?.then((fn) => fn());
progressFinishedListener?.then((fn) => fn());
});
async function handleFileSelect() {
if (TAURI_ENV) {
// Tauri模式使用文件对话框支持多选
try {
const selected = await open({
multiple: true,
filters: [
{
name: "视频文件",
extensions: [
"mp4",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"m4v",
"webm",
],
},
],
});
// 检查用户是否取消了选择
if (!selected) return;
if (Array.isArray(selected) && selected.length > 1) {
// 批量导入:多个文件
selectedFiles = selected;
await startBatchImport();
} else if (Array.isArray(selected) && selected.length === 1) {
// 单文件导入:数组中的单个文件
await setSelectedFile(selected[0]);
} else if (typeof selected === "string") {
// 单文件导入:直接返回字符串路径
await setSelectedFile(selected);
}
} catch (error) {
console.error("文件选择失败:", error);
alert("文件选择失败: " + error);
}
} else {
// Web模式触发文件输入
fileInput?.click();
}
}
async function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (files && files.length > 0) {
if (files.length > 1) {
// 批量上传模式
await uploadAndImportMultipleFiles(Array.from(files));
} else {
// 单文件上传模式(保持现有逻辑)
const file = files[0];
// 提前设置文件信息,提升用户体验
selectedFileName = file.name;
videoTitle = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
selectedFileSize = file.size;
await uploadFile(file);
}
}
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
dragOver = false;
if (TAURI_ENV) return; // Tauri模式不支持拖拽
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
// 检查文件类型
const allowedTypes = [
"video/mp4",
"video/x-msvideo",
"video/quicktime",
"video/x-ms-wmv",
"video/x-flv",
"video/x-m4v",
"video/webm",
"video/x-matroska",
];
if (
allowedTypes.includes(file.type) ||
file.name.match(/\.(mp4|mkv|avi|mov|wmv|flv|m4v|webm)$/i)
) {
// 提前设置文件信息,提升用户体验
selectedFileName = file.name;
videoTitle = file.name.replace(/\.[^/.]+$/, ""); // 去掉扩展名
selectedFileSize = file.size;
await uploadFile(file);
} else {
alert(
"请选择支持的视频文件格式 (MP4, MKV, AVI, MOV, WMV, FLV, M4V, WebM)"
);
}
}
}
async function uploadFile(file: File) {
uploading = true;
uploadProgress = 0;
try {
const formData = new FormData();
formData.append("file", file);
formData.append("roomId", String(roomId || 0));
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
uploadProgress = Math.round((e.loaded / e.total) * 100);
}
});
// 处理上传完成
xhr.addEventListener("load", async () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.code === 0 && response.data) {
// 使用本地文件信息,更快更准确
await setSelectedFile(response.data.filePath, file.size);
} else {
throw new Error(response.message || "上传失败");
}
} else {
throw new Error(`上传失败: HTTP ${xhr.status}`);
}
uploading = false;
});
xhr.addEventListener("error", () => {
alert("上传失败:网络错误");
uploading = false;
});
xhr.open("POST", `${ENDPOINT}/api/upload_file`);
xhr.send(formData);
} catch (error) {
console.error("上传失败:", error);
alert("上传失败: " + error);
uploading = false;
}
}
async function uploadAndImportMultipleFiles(files: File[]) {
batchImporting = true;
importing = true;
totalFiles = files.length;
currentFileIndex = 0;
importProgress = `准备批量上传和导入 ${totalFiles} 个文件...`;
// 设置当前处理的文件名列表
const fileNames = files.map((file) => file.name);
try {
// 验证所有文件格式
const allowedTypes = [
"video/mp4",
"video/x-msvideo",
"video/quicktime",
"video/x-ms-wmv",
"video/x-flv",
"video/x-m4v",
"video/webm",
"video/x-matroska",
];
for (const file of files) {
if (
!allowedTypes.includes(file.type) &&
!file.name.match(/\.(mp4|mkv|avi|mov|wmv|flv|m4v|webm)$/i)
) {
throw new Error(`不支持的文件格式: ${file.name}`);
}
}
const formData = new FormData();
formData.append("room_id", String(roomId || 0));
files.forEach((file) => {
formData.append("files", file);
});
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
importProgress = `批量上传进度: ${progress}%`;
// 根据进度估算当前正在上传的文件
const estimatedCurrentIndex = Math.min(
Math.floor((progress / 100) * totalFiles),
totalFiles - 1
);
currentFileName = fileNames[estimatedCurrentIndex] || fileNames[0];
}
});
// 处理上传完成
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.code === 0) {
// 批量上传和导入成功,关闭对话框并刷新列表
showDialog = false;
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
resetBatchImportState();
dispatch("imported");
} else {
throw new Error(response.message || "批量导入失败");
}
} else {
throw new Error(`批量上传失败: HTTP ${xhr.status}`);
}
});
xhr.addEventListener("error", () => {
alert("批量上传失败:网络错误");
resetBatchImportState();
});
xhr.open("POST", `${ENDPOINT}/api/upload_and_import_files`);
xhr.send(formData);
} catch (error) {
console.error("批量上传失败:", error);
alert("批量上传失败: " + error);
resetBatchImportState();
}
}
async function setSelectedFile(filePath: string, fileSize?: number) {
selectedFilePath = filePath;
selectedFileName = filePath.split(/[/\\]/).pop() || "";
videoTitle = selectedFileName.replace(/\.[^/.]+$/, ""); // 去掉扩展名
if (fileSize !== undefined) {
selectedFileSize = fileSize;
} else {
// 获取文件大小 (Tauri模式)
try {
selectedFileSize = await invoke("get_file_size", { filePath });
} catch (e) {
selectedFileSize = 0;
}
}
}
/**
* 开始批量导入视频文件
*/
async function startBatchImport() {
if (selectedFiles.length === 0) return;
batchImporting = true;
importing = true;
totalFiles = selectedFiles.length;
currentFileIndex = 0;
importProgress = `准备批量导入 ${totalFiles} 个文件...`;
try {
const eventId = "batch_import_" + Date.now();
currentImportEventId = eventId;
await invoke("batch_import_external_videos", {
eventId: eventId,
filePaths: selectedFiles,
roomId: roomId || 0,
});
// 注意:成功处理在 progressFinishedListener 中进行
} catch (error) {
console.error("批量导入失败:", error);
alert("批量导入失败: " + error);
resetBatchImportState();
}
}
/**
* 重置批量导入状态
*/
function resetBatchImportState() {
batchImporting = false;
importing = false;
currentImportEventId = null;
importProgress = "";
selectedFiles = [];
totalFiles = 0;
currentFileIndex = 0;
}
async function startImport() {
if (!selectedFilePath) return;
importing = true;
importProgress = "准备导入...";
try {
const eventId = "import_" + Date.now();
currentImportEventId = eventId;
await invoke("import_external_video", {
eventId: eventId,
filePath: selectedFilePath,
title: videoTitle,
roomId: roomId || 0,
});
// 注意成功处理移到了progressFinishedListener中
} catch (error) {
console.error("导入失败:", error);
alert("导入失败: " + error);
importing = false;
currentImportEventId = null;
importProgress = "";
}
}
/**
* 关闭对话框并重置所有状态
*/
function closeDialog() {
showDialog = false;
// 重置单文件导入状态
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
uploading = false;
uploadProgress = 0;
importing = false;
currentImportEventId = null;
importProgress = "";
// 重置批量导入状态
resetBatchImportState();
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
if (!TAURI_ENV) {
dragOver = true;
}
}
function handleDragLeave() {
dragOver = false;
}
</script>
<!-- 隐藏的文件输入 -->
{#if !TAURI_ENV}
<input
bind:this={fileInput}
type="file"
accept=".mp4,.mkv,.avi,.mov,.wmv,.flv,.m4v,.webm,video/*"
multiple
style="display: none"
on:change={handleFileInputChange}
/>
{/if}
{#if showDialog}
<div
class="fixed inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
>
<div
class="bg-white dark:bg-[#323234] rounded-xl shadow-xl w-full max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col"
>
<div class="flex-1 overflow-y-auto">
<div class="p-6 space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
导入外部视频
</h3>
<button
on:click={closeDialog}
class="text-gray-400 hover:text-gray-600"
>
<X class="w-5 h-5" />
</button>
</div>
<!-- 文件选择区域 -->
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors {dragOver
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600'}"
on:dragover={handleDragOver}
on:dragleave={handleDragLeave}
on:drop={handleDrop}
>
{#if uploading}
<!-- 上传进度 -->
<div class="space-y-4">
<Upload
class="w-12 h-12 text-blue-500 mx-auto animate-bounce"
/>
<p class="text-sm text-gray-900 dark:text-white font-medium">
上传中...
</p>
<div
class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"
>
<div
class="bg-blue-500 h-2 rounded-full transition-all"
style="width: {uploadProgress}%"
></div>
</div>
<p class="text-xs text-gray-500">{uploadProgress}%</p>
</div>
{:else if batchImporting}
<!-- 批量导入中 -->
<div class="space-y-4">
<div class="flex items-center justify-center">
<svg
class="animate-spin h-12 w-12 text-blue-500"
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>
</div>
<p class="text-sm text-gray-900 dark:text-white font-medium">
批量导入进行中...
</p>
<div class="text-xs text-gray-500">{importProgress}</div>
{#if currentFileName}
<div class="text-xs text-gray-400 break-all">
当前文件:{currentFileName}
</div>
{/if}
</div>
{:else if selectedFilePath}
<!-- 已选择文件 -->
<div class="space-y-4">
<div class="flex items-center justify-center">
<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">
大小: {formatFileSize(selectedFileSize)}
</p>
<p
class="text-xs text-gray-400 break-all"
title={selectedFilePath}
>
{selectedFilePath}
</p>
<button
on:click={() => {
selectedFilePath = null;
selectedFileName = "";
selectedFileSize = 0;
videoTitle = "";
}}
class="text-sm text-red-500 hover:text-red-700"
>
重新选择
</button>
</div>
{:else}
<!-- 选择文件提示 -->
<div class="space-y-4">
<Upload class="w-12 h-12 text-gray-400 mx-auto" />
{#if TAURI_ENV}
<p class="text-sm text-gray-600 dark:text-gray-400">
点击按钮选择视频文件(支持多选)
</p>
{:else}
<p class="text-sm text-gray-600 dark:text-gray-400">
拖拽视频文件到此处,或点击按钮选择文件(支持多选)
</p>
{/if}
<p class="text-xs text-gray-500 dark:text-gray-500">
支持 MP4, MKV, AVI, MOV, WMV, FLV, M4V, WebM 格式
</p>
</div>
{/if}
{#if !uploading && !selectedFilePath && !batchImporting}
<button
on:click={handleFileSelect}
class="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
{TAURI_ENV ? "选择文件" : "选择或拖拽文件"}
</button>
{/if}
</div>
<!-- 视频信息编辑 -->
{#if selectedFilePath}
<div class="space-y-4">
<div>
<label
for="video-title-input"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
视频标题
</label>
<input
id="video-title-input"
type="text"
bind:value={videoTitle}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
placeholder="输入视频标题"
/>
</div>
</div>
{/if}
</div>
</div>
<!-- 操作按钮 - 固定在底部 -->
<div
class="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-[#2a2a2c]"
>
<div class="flex justify-end space-x-3">
<button
on:click={closeDialog}
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
取消
</button>
<button
on:click={startImport}
disabled={!selectedFilePath ||
importing ||
!videoTitle.trim() ||
uploading ||
batchImporting}
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"
>
{#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>
</div>
</div>
{/if}

View File

@@ -5,12 +5,12 @@
ForwardOutline,
ClockOutline,
} from "flowbite-svelte-icons";
import type { Marker } from "./interface";
import type { Marker } from "../interface";
import { createEventDispatcher } from "svelte";
import { Tooltip } from "flowbite-svelte";
import { invoke, TAURI_ENV } from "../lib/invoker";
import { invoke, TAURI_ENV } from "../invoker";
import { save } from "@tauri-apps/plugin-dialog";
import type { RecordItem } from "./db";
import type { RecordItem } from "../db";
const dispatch = createEventDispatcher();
export let archive: RecordItem;
export let markers: Marker[] = [];

View File

@@ -3,9 +3,9 @@
</script>
<script lang="ts">
import { invoke, TAURI_ENV, ENDPOINT, listen, log } from "../lib/invoker";
import type { AccountInfo } from "./db";
import type { Marker, RecorderList, RecorderInfo } from "./interface";
import { invoke, TAURI_ENV, ENDPOINT, listen, log } from "../invoker";
import type { AccountInfo } from "../db";
import type { Marker, RecorderList, RecorderInfo } from "../interface";
import { createEventDispatcher } from "svelte";
import {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { X } from "lucide-svelte";
import { parseSubtitleStyle, type SubtitleStyle } from "./interface";
import { parseSubtitleStyle, type SubtitleStyle } from "../interface";
export let show = false;
export let onClose: () => void;
@@ -183,7 +183,9 @@
<h3 class="text-sm font-medium text-gray-300">对齐和边距</h3>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label for="alignment-select" class="block text-sm text-gray-400">对齐方式</label>
<label for="alignment-select" class="block text-sm text-gray-400"
>对齐方式</label
>
<select
id="alignment-select"
bind:value={style.alignment}
@@ -199,7 +201,9 @@
</select>
</div>
<div class="space-y-2">
<label for="margin-v-input" class="block text-sm text-gray-400">垂直边距</label>
<label for="margin-v-input" class="block text-sm text-gray-400"
>垂直边距</label
>
<input
id="margin-v-input"
type="number"
@@ -212,7 +216,9 @@
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label for="margin-l-input" class="block text-sm text-gray-400">左边距</label>
<label for="margin-l-input" class="block text-sm text-gray-400"
>左边距</label
>
<input
id="margin-l-input"
type="number"
@@ -223,7 +229,9 @@
/>
</div>
<div class="space-y-2">
<label for="margin-r-input" class="block text-sm text-gray-400">右边距</label>
<label for="margin-r-input" class="block text-sm text-gray-400"
>右边距</label
>
<input
id="margin-r-input"
type="number"

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { invoke } from "../lib/invoker";
import { invoke } from "../invoker";
import { ChevronDownOutline } from "flowbite-svelte-icons";
import { scale } from "svelte/transition";
import type { Children, VideoType } from "./interface";
import type { Children, VideoType } from "../interface";
export let value = 0;
let parentSelected: VideoType;
let areaSelected: Children;

View File

@@ -24,14 +24,21 @@
type Profile,
type Config,
default_profile,
} from "./interface";
} from "../interface";
import SubtitleStyleEditor from "./SubtitleStyleEditor.svelte";
import CoverEditor from "./CoverEditor.svelte";
import TypeSelect from "./TypeSelect.svelte";
import { invoke, TAURI_ENV, listen, log, close_window, convertCoverSrc } from "../lib/invoker";
import {
invoke,
TAURI_ENV,
listen,
log,
close_window,
convertCoverSrc,
} from "../invoker";
import { onDestroy, onMount } from "svelte";
import { listen as tauriListen } from "@tauri-apps/api/event";
import type { AccountInfo } from "./db";
import type { AccountInfo } from "../db";
export let show = false;
export let video: VideoItem;
@@ -458,7 +465,7 @@
// 当视频改变时重新初始化切片时间只在视频ID改变时触发
$: if (video && videoElement?.duration && video.id !== lastVideoId) {
lastVideoId = video.id;
// 切换视频时重置切片时间 - 不设置默认值,等待用户输入
// 切换视频时重置切片时间 - 不设置默认值,等待用户输入
clipStartTime = 0;
clipEndTime = 0;
clipTitle = "";
@@ -523,7 +530,7 @@
function setClipStartTime() {
if (videoElement) {
const newStartTime = videoElement.currentTime;
// 如果没有选区(首次设置起点),自动将终点设置为视频结尾
if (!clipTimesSet || clipEndTime === 0) {
clipStartTime = newStartTime;
@@ -536,7 +543,7 @@
} else {
clipStartTime = newStartTime;
}
clipTimesSet = true; // 标记用户已设置切片时间
}
}
@@ -544,20 +551,24 @@
function setClipEndTime() {
if (videoElement) {
const newEndTime = videoElement.currentTime;
// 如果没有选区(首次设置终点),自动将起点设置为视频开头
if (!clipTimesSet || clipStartTime === 0) {
clipStartTime = 0; // 自动设置为视频开头
clipEndTime = newEndTime;
}
// 如果新的结束时间在现有开始时间之前,清空选区重新开始
else if (clipTimesSet && clipStartTime > 0 && newEndTime <= clipStartTime) {
else if (
clipTimesSet &&
clipStartTime > 0 &&
newEndTime <= clipStartTime
) {
clipStartTime = 0; // 清空开始时间
clipEndTime = newEndTime;
} else {
clipEndTime = newEndTime;
}
clipTimesSet = true; // 标记用户已设置切片时间
}
}
@@ -582,15 +593,15 @@
async function generateClip() {
if (!video) return;
// 如果没有设置切片标题,则以当前本地时间戳命名
if (!clipTitle.trim()) {
const now = new Date();
const pad = (n) => n.toString().padStart(2, '0');
const pad = (n) => n.toString().padStart(2, "0");
const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
clipTitle = `clip_${timestamp}`;
}
if (clipStartTime >= clipEndTime) {
alert("开始时间必须小于结束时间");
return;
@@ -603,14 +614,14 @@
clipping = true;
current_clip_event_id = generateEventId();
try {
await invoke("clip_video", {
eventId: current_clip_event_id,
parentVideoId: video.id,
startTime: clipStartTime,
endTime: clipEndTime,
clipTitle: clipTitle
clipTitle: clipTitle,
});
} catch (error) {
console.error("切片失败:", error);
@@ -631,7 +642,7 @@
if (!video) {
return false;
}
// 只要不是正在录制的视频(status !== -1),都可以切片
// 这包括:
// - 导入的视频 (imported)
@@ -643,10 +654,10 @@
// 键盘快捷键处理
function handleKeydown(event: KeyboardEvent) {
if (!show || !isVideoLoaded) return;
// 如果在输入框中,不处理某些快捷键
const isInInput = (event.target as HTMLElement)?.tagName === 'INPUT';
const isInInput = (event.target as HTMLElement)?.tagName === "INPUT";
switch (event.key) {
case "【":
case "[":
@@ -686,7 +697,10 @@
if (!isInInput) {
event.preventDefault();
if (videoElement) {
videoElement.currentTime = Math.max(0, videoElement.currentTime - 5);
videoElement.currentTime = Math.max(
0,
videoElement.currentTime - 5
);
}
}
break;
@@ -694,7 +708,10 @@
if (!isInInput) {
event.preventDefault();
if (videoElement) {
videoElement.currentTime = Math.min(videoElement.duration, videoElement.currentTime + 5);
videoElement.currentTime = Math.min(
videoElement.duration,
videoElement.currentTime + 5
);
}
}
break;
@@ -722,8 +739,6 @@
}
}
function togglePlay() {
if (isPlaying) {
videoElement.pause();
@@ -745,8 +760,6 @@
isPlaying = false;
}
function handleTimelineClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
@@ -762,33 +775,31 @@
videoElement.currentTime = time;
}
// 进度条拖动事件处理
function handleSeekbarMouseDown(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (!videoElement || !seekbarElement) return;
isDraggingSeekbar = true;
wasPlayingBeforeDrag = isPlaying;
// 先初始化预览时间为当前时间,避免跳跃
previewTime = videoElement.currentTime;
// 然后计算鼠标位置对应的时间
const rect = seekbarElement.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const newTime = (x / rect.width) * videoElement.duration;
previewTime = newTime;
// 暂停播放
if (isPlaying) {
videoElement.pause();
isPlaying = false;
}
// 添加全局事件监听器
document.addEventListener("mousemove", handleSeekbarMouseMove);
document.addEventListener("mouseup", handleSeekbarMouseUp);
@@ -796,7 +807,7 @@
function handleSeekbarMouseMove(e: MouseEvent) {
if (!isDraggingSeekbar || !seekbarElement || !videoElement) return;
const rect = seekbarElement.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const newTime = (x / rect.width) * videoElement.duration;
@@ -805,20 +816,20 @@
function handleSeekbarMouseUp(e: MouseEvent) {
if (!isDraggingSeekbar) return;
// 应用最终时间
if (videoElement) {
videoElement.currentTime = previewTime;
// 立即同步currentTime变量避免视觉偏移
currentTime = previewTime;
}
isDraggingSeekbar = false;
// 移除全局事件监听器
document.removeEventListener("mousemove", handleSeekbarMouseMove);
document.removeEventListener("mouseup", handleSeekbarMouseUp);
// 恢复播放状态
if (wasPlayingBeforeDrag && videoElement) {
videoElement.play();
@@ -826,8 +837,6 @@
}
}
function addSubtitle() {
const newStartTime = currentTime;
const newEndTime = Math.min(currentTime + 5, videoElement.duration);
@@ -844,17 +853,20 @@
function updateSubtitleTime(index: number, isStart: boolean, time: number) {
if (!videoElement?.duration) return; // 防御性检查
subtitles = subtitles.map((sub, i) => {
if (i !== index) return sub;
if (isStart) {
// 开始时间约束不能小于0不能大于结束时间-0.1秒
const newStartTime = Math.max(0, Math.min(time, sub.endTime - 0.1));
return { ...sub, startTime: newStartTime };
} else {
// 结束时间约束:不能小于开始时间+0.1秒,不能大于视频总长度
const newEndTime = Math.min(videoElement.duration, Math.max(time, sub.startTime + 0.1));
const newEndTime = Math.min(
videoElement.duration,
Math.max(time, sub.startTime + 0.1)
);
return { ...sub, endTime: newEndTime };
}
});
@@ -863,16 +875,21 @@
function moveSubtitle(index: number, newStartTime: number) {
if (!videoElement?.duration) return;
const sub = subtitles[index];
const duration = sub.endTime - sub.startTime;
// 约束开始时间到有效范围 [0, videoDuration - duration]
const finalStartTime = Math.max(0, Math.min(newStartTime, videoElement.duration - duration));
const finalStartTime = Math.max(
0,
Math.min(newStartTime, videoElement.duration - duration)
);
const finalEndTime = finalStartTime + duration;
subtitles = subtitles.map((s, i) =>
i === index ? { ...s, startTime: finalStartTime, endTime: finalEndTime } : s
i === index
? { ...s, startTime: finalStartTime, endTime: finalEndTime }
: s
);
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
}
@@ -928,7 +945,11 @@
}
// 辅助函数:统一开始块拖拽
function startBlockDragging(index: number, mouseTime: number, startTime: number) {
function startBlockDragging(
index: number,
mouseTime: number,
startTime: number
) {
draggingBlock = index;
dragOffset = mouseTime - startTime;
document.addEventListener("mousemove", handleBlockMouseMove);
@@ -942,10 +963,11 @@
const mouseTime = (x / rect.width) * videoElement.duration;
// 计算边缘检测参数
const blockWidth = rect.width * ((sub.endTime - sub.startTime) / videoElement.duration);
const blockWidth =
rect.width * ((sub.endTime - sub.startTime) / videoElement.duration);
const relativeX = x - rect.width * (sub.startTime / videoElement.duration);
const edgeSize = Math.min(5, Math.max(2, blockWidth / 3));
// 确定拖拽类型
if (relativeX < edgeSize) {
// 左边缘:调整开始时间
@@ -1002,7 +1024,8 @@
// 字幕块位置应该是相对于时间轴容器的百分比,不需要乘以缩放因子
// 因为容器本身已经通过 style="width: {100 * timelineScale}%" 进行了缩放
const start = (subtitle.startTime / videoElement.duration) * 100;
const width = ((subtitle.endTime - subtitle.startTime) / videoElement.duration) * 100;
const width =
((subtitle.endTime - subtitle.startTime) / videoElement.duration) * 100;
return `left: ${start}%; width: ${width}%;`;
}
@@ -1188,7 +1211,9 @@
{:else}
<Scissors class="w-4 h-4" />
{/if}
<span id="generate-clip-prompt">{clipping ? "生成中..." : "生成切片"}</span>
<span id="generate-clip-prompt"
>{clipping ? "生成中..." : "生成切片"}</span
>
</button>
{/if}
<button
@@ -1290,16 +1315,24 @@
<div class="flex-1 flex flex-col">
<!-- 切片控制信息条 -->
{#if canBeClipped(video)}
<div class="bg-black px-4 py-2 flex items-center justify-between text-sm">
<div
class="bg-black px-4 py-2 flex items-center justify-between text-sm"
>
<div class="flex items-center space-x-6">
<div class="text-gray-300">
切片起点: <span class="text-[#0A84FF] font-mono">{formatTime(clipStartTime)}</span>
切片起点: <span class="text-[#0A84FF] font-mono"
>{formatTime(clipStartTime)}</span
>
</div>
<div class="text-gray-300">
切片终点: <span class="text-[#0A84FF] font-mono">{formatTime(clipEndTime)}</span>
切片终点: <span class="text-[#0A84FF] font-mono"
>{formatTime(clipEndTime)}</span
>
</div>
<div class="text-gray-300">
时长: <span class="text-white font-mono">{formatTime(clipEndTime - clipStartTime)}</span>
时长: <span class="text-white font-mono"
>{formatTime(clipEndTime - clipStartTime)}</span
>
</div>
</div>
</div>
@@ -1317,24 +1350,74 @@
on:loadedmetadata={handleVideoLoaded}
on:click={togglePlay}
/>
<!-- 切片快捷键说明 -->
{#if canBeClipped(video)}
<div id="overlay" class="absolute top-2 left-2 rounded-md px-2 py-2 flex flex-col pointer-events-none" style="background-color: rgba(0, 0, 0, 0.5); color: white; font-size: 0.8em;">
<div
id="overlay"
class="absolute top-2 left-2 rounded-md px-2 py-2 flex flex-col pointer-events-none"
style="background-color: rgba(0, 0, 0, 0.5); color: white; font-size: 0.8em;"
>
<p style="margin: 0;">
快捷键说明
<kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">h</kbd>展开
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>h</kbd
>展开
</p>
{#if show_detail}
<span>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">[</kbd>设定选区开始</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">]</kbd>设定选区结束</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">q</kbd>跳转到选区开始</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">e</kbd>跳转到选区结束</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">g</kbd>生成切片</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">c</kbd>清除选区</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;">Space</kbd>播放/暂停</p>
<p style="margin: 0;"><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"></kbd><kbd style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"></kbd>前进/后退</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>[</kbd
>设定选区开始
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>]</kbd
>设定选区结束
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>q</kbd
>跳转到选区开始
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>e</kbd
>跳转到选区结束
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>g</kbd
>生成切片
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>c</kbd
>清除选区
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>Space</kbd
>播放/暂停
</p>
<p style="margin: 0;">
<kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>←</kbd
><kbd
style="border: 1px solid white; padding: 0 0.2em; border-radius: 0.2em; margin: 4px;"
>→</kbd
>前进/后退
</p>
</span>
{/if}
</div>
@@ -1368,9 +1451,11 @@
</div>
</div>
<!-- 字幕控制栏 -->
<!-- 字幕控制栏 -->
<div class="bg-[#1c1c1e] border-t border-gray-800/50 p-2">
<div class="h-8 px-4 flex items-center justify-between border-b border-gray-800/50">
<div
class="h-8 px-4 flex items-center justify-between border-b border-gray-800/50"
>
<!-- 左侧控制 -->
<div class="flex items-center space-x-2">
<!-- 缩放控制 -->
@@ -1407,17 +1492,38 @@
on:click={toggleMute}
>
{#if isMuted || volume === 0}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
{:else if volume < 0.5}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 5L6 9H2v6h4l5 4V5z" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 5L6 9H2v6h4l5 4V5z" />
</svg>
{/if}
@@ -1462,26 +1568,35 @@
>
<!-- 切片选区可视化 -->
{#if canBeClipped(video) && clipTimesSet}
<div class="absolute top-0 left-0 right-0 h-1 group-hover:h-1.5 transition-all duration-200 z-15">
<div
class="absolute top-0 left-0 right-0 h-1 group-hover:h-1.5 transition-all duration-200 z-15"
>
<!-- 切片选中区域 -->
<div
<div
class="absolute h-full bg-green-400/80 transition-all duration-200"
style="left: {(clipStartTime / (videoElement?.duration || 1)) * 100}%; right: {100 - (clipEndTime / (videoElement?.duration || 1)) * 100}%"
style="left: {(clipStartTime /
(videoElement?.duration || 1)) *
100}%; right: {100 -
(clipEndTime / (videoElement?.duration || 1)) * 100}%"
></div>
<!-- 切片起点标记 -->
<div
<div
class="absolute h-full w-0.5 bg-green-500 transition-all duration-200"
style="left: {(clipStartTime / (videoElement?.duration || 1)) * 100}%"
style="left: {(clipStartTime /
(videoElement?.duration || 1)) *
100}%"
></div>
<!-- 切片终点标记 -->
<div
<div
class="absolute h-full w-0.5 bg-green-500 transition-all duration-200"
style="left: {(clipEndTime / (videoElement?.duration || 1)) * 100}%; transform: translateX(-100%)"
style="left: {(clipEndTime /
(videoElement?.duration || 1)) *
100}%; transform: translateX(-100%)"
></div>
</div>
{/if}
<!-- 播放进度条容器 (借鉴Shaka Player样式) -->
<div
<div
bind:this={seekbarElement}
class="shaka-seek-bar-container absolute top-2 left-0 right-0 h-1 group-hover:h-1.5 bg-white/30 rounded-full cursor-pointer transition-all duration-200 z-10"
class:dragging={isDraggingSeekbar}
@@ -1491,15 +1606,22 @@
<div
class="h-full bg-[#0A84FF] rounded-full pointer-events-none transition-all duration-200"
class:no-transition={isDraggingSeekbar}
style="width: {((isDraggingSeekbar ? previewTime : currentTime) / (videoElement?.duration || 1)) * 100}%"
>
</div>
style="width: {((isDraggingSeekbar
? previewTime
: currentTime) /
(videoElement?.duration || 1)) *
100}%"
></div>
<!-- 播放进度条滑块 (hover或拖动时显示) -->
<div
<div
class="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full border-2 border-[#0A84FF] shadow-lg z-30 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
class:opacity-100={isDraggingSeekbar}
style="left: calc({((isDraggingSeekbar ? previewTime : currentTime) / (videoElement?.duration || 1)) * 100}% - 6px)"
style="left: calc({((isDraggingSeekbar
? previewTime
: currentTime) /
(videoElement?.duration || 1)) *
100}% - 6px)"
></div>
</div>
@@ -1725,7 +1847,6 @@
{/each}
</div>
</div>
{:else if activeTab === "upload"}
<!-- 投稿 Tab 内容 -->
<div class="p-4 space-y-6">
@@ -1749,19 +1870,31 @@
class="relative rounded-xl overflow-hidden bg-black/20 border border-gray-800/50"
>
{#if video.cover && video.cover.trim() !== ""}
<img
src={video.cover}
alt="视频封面"
class="w-full"
<img
src={video.cover}
alt="视频封面"
class="w-full"
on:error={handleCoverError}
style:display={showDefaultCoverIcon ? 'none' : 'block'}
style:display={showDefaultCoverIcon ? "none" : "block"}
/>
{/if}
{#if !video.cover || video.cover.trim() === "" || showDefaultCoverIcon}
<div class="w-full aspect-video flex items-center justify-center bg-gray-800">
<div
class="w-full aspect-video flex items-center justify-center bg-gray-800"
>
<!-- 默认视频图标 -->
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
<svg
class="w-16 h-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
</div>
{/if}
@@ -1986,4 +2119,3 @@
background: #4a5568;
}
</style>

View File

@@ -55,6 +55,7 @@ export interface VideoItem {
status: number;
bvid: string;
title: string;
note: string;
desc: string;
tags: string;
area: number;
@@ -228,7 +229,7 @@ export function parseSubtitleStyle(style: SubtitleStyle): string {
return `FontName=${style.fontName},FontSize=${
style.fontSize
},PrimaryColour=${hexToAssColor(
style.fontColor
style.fontColor,
)},OutlineColour=${hexToAssColor(style.outlineColor)},Outline=${
style.outlineWidth
},Alignment=${style.alignment},MarginV=${style.marginV},MarginL=${
@@ -238,6 +239,7 @@ export function parseSubtitleStyle(style: SubtitleStyle): string {
export interface ClipRangeParams {
title: string;
note: string;
cover: string;
platform: string;
room_id: number;

View File

@@ -8,10 +8,10 @@
AIMessage,
ToolMessage,
} from "@langchain/core/messages";
import HumanMessageComponent from "../lib/HumanMessage.svelte";
import AIMessageComponent from "../lib/AIMessage.svelte";
import ProcessingMessageComponent from "../lib/ProcessingMessage.svelte";
import ToolMessageComponent from "../lib/ToolMessage.svelte";
import HumanMessageComponent from "../lib/components/HumanMessage.svelte";
import AIMessageComponent from "../lib/components/AIMessage.svelte";
import ProcessingMessageComponent from "../lib/components/ProcessingMessage.svelte";
import ToolMessageComponent from "../lib/components/ToolMessage.svelte";
let messages: any[] = [];
let inputMessage = "";

View File

@@ -2,7 +2,7 @@
import { invoke } from "../lib/invoker";
import { scale, fade } from "svelte/transition";
import { Textarea } from "flowbite-svelte";
import Image from "../lib/Image.svelte";
import Image from "../lib/components/Image.svelte";
import QRCode from "qrcode";
import type { AccountItem, AccountInfo } from "../lib/db";
import { Ellipsis, Plus } from "lucide-svelte";

View File

@@ -6,6 +6,7 @@
convertFileSrc,
} from "../lib/invoker";
import type { VideoItem } from "../lib/interface";
import ImportVideoDialog from "../lib/components/ImportVideoDialog.svelte";
import { onMount, onDestroy, tick } from "svelte";
import {
Play,
@@ -24,7 +25,9 @@
Scissors,
Download,
RotateCw,
Edit,
} from "lucide-svelte";
import { AnnotationOutline } from "flowbite-svelte-icons";
let videos: VideoItem[] = [];
let filteredVideos: VideoItem[] = [];
@@ -39,6 +42,11 @@
let videoToDelete: VideoItem | null = null;
let showImportDialog = false;
// 编辑备注相关状态
let showEditNoteDialog = false;
let videoToEditNote: VideoItem | null = null;
let editingNote = "";
onMount(async () => {
await loadVideos();
});
@@ -90,7 +98,7 @@
// 任务已完成延迟100ms确保状态同步后再重新加载
importProgressInfo = null;
stopProgressPolling();
// 延迟处理避免状态竞争
setTimeout(async () => {
loading = false;
@@ -100,7 +108,7 @@
}
} catch (error) {
console.error("轮询检查进度失败:", error);
// 不要立即重置状态,避免在网络错误时丢失进度
// 保持现有状态一段时间,然后重置
setTimeout(() => {
@@ -224,21 +232,27 @@
async function scanAndImportNewFiles() {
try {
// 扫描导入目录
const scanResponse = await invoke<{newFiles: string[]}>("scan_imported_directory");
const scanResponse = await invoke<{ newFiles: string[] }>(
"scan_imported_directory"
);
const newFiles = scanResponse.newFiles;
if (newFiles.length === 0) {
return {
hasProgress: false,
progressInfo: null
progressInfo: null,
};
}
// 批量就地导入
const result = await invoke("batch_import_in_place", {
const result: {
successful_imports: number;
failed_imports: number;
errors?: any;
} = await invoke("batch_import_in_place", {
filePaths: newFiles,
roomId: selectedRoomId || 0
roomId: selectedRoomId || 0,
});
// 导入完成后,立即查询是否有转换任务
@@ -254,7 +268,7 @@
// 返回转换任务信息
return {
hasProgress: true,
progressInfo: progressInfo
progressInfo: progressInfo,
};
}
@@ -268,14 +282,14 @@
return {
hasProgress: false,
progressInfo: null
progressInfo: null,
};
} catch (error) {
console.error("扫描导入目录失败:", error);
// 扫描失败不影响正常的视频列表加载
return {
hasProgress: false,
progressInfo: null
progressInfo: null,
};
}
}
@@ -297,6 +311,8 @@
aValue = a.title.toLowerCase();
bValue = b.title.toLowerCase();
break;
bValue = b.note;
break;
case "length":
aValue = a.length;
bValue = b.length;
@@ -483,11 +499,62 @@
a.click();
}
import ImportVideoDialog from "../lib/ImportVideoDialog.svelte";
// 编辑备注相关函数
function openEditNoteDialog(video: VideoItem) {
videoToEditNote = video;
editingNote = video.note || "";
showEditNoteDialog = true;
}
function closeEditNoteDialog() {
showEditNoteDialog = false;
videoToEditNote = null;
editingNote = "";
}
async function saveNote() {
if (!videoToEditNote) return;
try {
await invoke("update_video_note", {
id: videoToEditNote.id,
note: editingNote,
});
// 更新本地数据
videoToEditNote.note = editingNote;
// 更新筛选后的视频列表
const index = filteredVideos.findIndex(
(v) => v.id === videoToEditNote.id
);
if (index !== -1) {
filteredVideos[index].note = editingNote;
}
// 更新所有视频列表
const allIndex = videos.findIndex((v) => v.id === videoToEditNote.id);
if (allIndex !== -1) {
videos[allIndex].note = editingNote;
}
closeEditNoteDialog();
} catch (error) {
console.error("Failed to update video note:", error);
}
}
// 键盘事件处理
function handleKeydown(event: KeyboardEvent) {
if (showEditNoteDialog && event.key === "Escape") {
closeEditNoteDialog();
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<svelte:window on:keydown={handleKeydown} />
<div class="flex-1 p-6 overflow-auto custom-scrollbar-light bg-gray-50">
<div class="space-y-6">
<!-- Header -->
@@ -574,6 +641,22 @@
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'note'
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700/50 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'}"
on:click={() => toggleSort("note")}
>
备注
{#if sortBy === "note"}
{#if sortOrder === "asc"}
<ChevronUp class="w-3 h-3 inline ml-1" />
{:else}
<ChevronDown class="w-3 h-3 inline ml-1" />
{/if}
{/if}
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {sortBy ===
'platform'
@@ -676,15 +759,23 @@
<div class="text-center space-y-3 max-w-md">
<!-- 主要信息:醒目的转换状态 -->
<div class="flex items-center justify-center space-x-3">
<RotateCw class="w-7 h-7 text-blue-600 dark:text-blue-400 animate-spin" />
<span class="text-xl font-semibold text-blue-600 dark:text-blue-400">
<RotateCw
class="w-7 h-7 text-blue-600 dark:text-blue-400 animate-spin"
/>
<span
class="text-xl font-semibold text-blue-600 dark:text-blue-400"
>
正在转换视频
</span>
</div>
<!-- 副信息:文件名显示在小字部分 -->
<div class="text-sm text-gray-500 dark:text-gray-400 break-all px-4">
{importProgressInfo.fileName || importProgressInfo.file_name || '正在准备...'}
<div
class="text-sm text-gray-500 dark:text-gray-400 break-all px-4"
>
{importProgressInfo.fileName ||
importProgressInfo.file_name ||
"正在准备..."}
</div>
</div>
{:else}
@@ -733,6 +824,10 @@
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-64"
>视频</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-32"
>备注</th
>
<th
class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400 w-20"
>时长</th
@@ -860,6 +955,15 @@
</div>
</td>
<td class="px-4 py-3 w-20">
<div class="flex items-center space-x-2">
<AnnotationOutline class="table-icon text-gray-400" />
<span class="text-sm text-gray-800 truncate"
>{video.note}</span
>
</div>
</td>
<td class="px-4 py-3 w-20">
<div class="flex items-center space-x-2">
<Clock class="table-icon text-gray-400" />
@@ -917,6 +1021,13 @@
>
<Play class="table-icon" />
</button>
<button
class="p-1.5 text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors"
title="编辑备注"
on:click={() => openEditNoteDialog(video)}
>
<Edit class="table-icon" />
</button>
{#if !TAURI_ENV}
<button
class="p-1.5 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
@@ -972,7 +1083,7 @@
</div>
<div class="flex justify-center space-x-3">
<button
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
on:click={() => {
showDeleteConfirm = false;
videoToDelete = null;
@@ -998,6 +1109,108 @@
</div>
{/if}
<!-- Edit Note Dialog -->
{#if showEditNoteDialog && videoToEditNote}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 bg-black/30 dark:bg-black/50 z-50 flex items-center justify-center p-4"
on:click={closeEditNoteDialog}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="mac-modal w-[480px] max-w-full bg-white dark:bg-[#2d2d30] rounded-xl shadow-xl border border-gray-200 dark:border-gray-600 overflow-hidden"
on:click|stopPropagation
role="dialog"
aria-labelledby="edit-note-title"
aria-describedby="edit-note-description"
>
<!-- Header -->
<div
class="px-6 pt-6 pb-4 border-b border-gray-100 dark:border-gray-700/50"
>
<div class="flex items-center space-x-3">
<div
class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"
>
<Edit class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div class="min-w-0 flex-1">
<h3
id="edit-note-title"
class="text-base font-semibold text-gray-900 dark:text-white"
>
编辑切片备注
</h3>
<p
id="edit-note-description"
class="text-sm text-gray-500 dark:text-gray-400 truncate mt-0.5"
>
{videoToEditNote.title || videoToEditNote.file}
</p>
</div>
</div>
</div>
<!-- Content -->
<div class="px-6 py-5">
<div class="space-y-3">
<label
for="edit-note-textarea"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
备注内容
</label>
<div class="relative">
<textarea
id="edit-note-textarea"
bind:value={editingNote}
placeholder="为这个切片添加备注信息,如高光时刻、重要内容等..."
class="w-full px-3 py-2 text-sm
bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-500 rounded-md
text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
transition-colors duration-150 resize-none"
rows="5"
on:keydown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
saveNote();
}
}}
></textarea>
<!-- Helper text -->
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
按 ⌘+Enter 快速保存
</div>
</div>
</div>
</div>
<!-- Footer -->
<div
class="px-6 py-4 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-600"
>
<div class="flex justify-end space-x-3">
<button
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
on:click={closeEditNoteDialog}
>
取消
</button>
<button
class="w-24 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
on:click={saveNote}
>
保存
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- 导入视频对话框 -->
<ImportVideoDialog
bind:showDialog={showImportDialog}
@@ -1008,13 +1221,31 @@
<style>
/* macOS style modal */
.mac-modal {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
:global(.dark) .mac-modal {
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.3),
0 10px 10px -5px rgba(0, 0, 0, 0.1);
}
/* fixed icon size in tables */
:global(.table-icon) {
width: 1rem; /* 16px, same as Tailwind w-4 */
height: 1rem; /* 16px, same as Tailwind h-4 */
flex: 0 0 auto;
}
/* macOS style textarea */
.mac-modal textarea {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
}
.mac-modal textarea:focus {
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.3);
}
</style>

View File

@@ -4,7 +4,7 @@
import { fade, scale } from "svelte/transition";
import { Dropdown, DropdownItem } from "flowbite-svelte";
import type { RecorderList, RecorderInfo } from "../lib/interface";
import Image from "../lib/Image.svelte";
import Image from "../lib/components/Image.svelte";
import type { RecordItem } from "../lib/db";
import {
Ellipsis,
@@ -16,9 +16,9 @@
X,
History,
} from "lucide-svelte";
import BilibiliIcon from "../lib/BilibiliIcon.svelte";
import DouyinIcon from "../lib/DouyinIcon.svelte";
import AutoRecordIcon from "../lib/AutoRecordIcon.svelte";
import BilibiliIcon from "../lib/components/BilibiliIcon.svelte";
import DouyinIcon from "../lib/components/DouyinIcon.svelte";
import AutoRecordIcon from "../lib/components/AutoRecordIcon.svelte";
import { onMount } from "svelte";
export let room_count = 0;
@@ -517,7 +517,7 @@
</div>
<div class="flex justify-center space-x-3">
<button
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
class="w-24 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
on:click={() => {
deleteModal = false;
}}
@@ -650,7 +650,7 @@
<div class="flex justify-end space-x-3">
<button
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-[#f5f5f7] dark:hover:bg-[#3a3a3c] rounded-lg transition-colors"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
on:click={() => {
addModal = false;
}}