Files
bili-shadowreplay/src/lib/components/ImportVideoDialog.svelte
2025-09-26 21:30:55 +08:00

712 lines
22 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { invoke, TAURI_ENV, ENDPOINT, listen } 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);
}
}
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}