mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-25 04:22:24 +08:00
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:
4
src-tauri/src/constants.rs
Normal file
4
src-tauri/src/constants.rs
Normal 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]";
|
||||
@@ -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)
|
||||
|
||||
@@ -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", ×tamp.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
BIN
src-tauri/tests/video/test.mp4
Normal file
BIN
src-tauri/tests/video/test.mp4
Normal file
Binary file not shown.
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
@@ -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 = "";
|
||||
722
src/lib/components/ImportVideoDialog.svelte
Normal file
722
src/lib/components/ImportVideoDialog.svelte
Normal 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}
|
||||
@@ -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[] = [];
|
||||
@@ -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 {
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user