mirror of
https://github.com/Xinrea/bili-shadowreplay.git
synced 2025-11-24 20:15:34 +08:00
2159 lines
72 KiB
Svelte
2159 lines
72 KiB
Svelte
<script lang="ts">
|
||
import {
|
||
Play,
|
||
ArrowLeft,
|
||
Plus,
|
||
Minus,
|
||
Pause,
|
||
Film,
|
||
Settings,
|
||
Trash2,
|
||
BrainCircuit,
|
||
Eraser,
|
||
Download,
|
||
Pen,
|
||
Scissors,
|
||
} from "lucide-svelte";
|
||
import {
|
||
generateEventId,
|
||
parseSubtitleStyle,
|
||
type ProgressFinished,
|
||
type ProgressUpdate,
|
||
type SubtitleStyle,
|
||
type VideoItem,
|
||
type Profile,
|
||
type Config,
|
||
default_profile,
|
||
} 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,
|
||
get_cover,
|
||
} from "../invoker";
|
||
import { onDestroy, onMount } from "svelte";
|
||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
||
import type { AccountInfo } from "../db";
|
||
|
||
export let show = false;
|
||
export let video: VideoItem;
|
||
export let roomId: number;
|
||
export let videos: any[] = [];
|
||
export let onVideoChange: ((video: VideoItem) => void) | undefined =
|
||
undefined;
|
||
export let onVideoListUpdate: (() => Promise<void>) | undefined = undefined;
|
||
|
||
interface Subtitle {
|
||
startTime: number;
|
||
endTime: number;
|
||
text: string;
|
||
}
|
||
|
||
let subtitles: Subtitle[] = [];
|
||
let currentTime = 0;
|
||
let currentSubtitle = "";
|
||
let videoElement: HTMLVideoElement;
|
||
let showDefaultCoverIcon = false;
|
||
let timelineWidth = 0;
|
||
|
||
// 当视频改变时重置封面错误状态
|
||
$: if (video) {
|
||
showDefaultCoverIcon = false;
|
||
}
|
||
let timelineElement: HTMLElement;
|
||
let draggingSubtitle: { index: number; isStart: boolean } | null = null;
|
||
let draggingBlock: number | null = null;
|
||
let timelineScale = 1; // 时间轴缩放比例,1 表示正常大小
|
||
let isPlaying = false;
|
||
let timeMarkers: number[] = [];
|
||
let dragOffset: number = 0; // 添加拖动偏移量
|
||
let isVideoLoaded = false;
|
||
let showStyleEditor = false;
|
||
let volume = 1;
|
||
let previousVolume = 1;
|
||
let isMuted = false;
|
||
let currentSubtitleIndex = -1;
|
||
let subtitleElements: HTMLElement[] = [];
|
||
let timelineContainer: HTMLElement;
|
||
let showEncodeModal = false;
|
||
let videoWidth = 0;
|
||
let videoHeight = 0;
|
||
let subtitleStyle: SubtitleStyle = {
|
||
fontName: "Arial",
|
||
fontSize: 18,
|
||
fontColor: "#FFFFFF",
|
||
outlineColor: "#000000",
|
||
outlineWidth: 2,
|
||
alignment: 2,
|
||
marginV: 20,
|
||
marginL: 20,
|
||
marginR: 20,
|
||
};
|
||
|
||
let current_encode_event_id = null;
|
||
let current_generate_event_id = null;
|
||
let windowCloseUnlisten: (() => void) | null = null;
|
||
let activeTab = "subtitle"; // 添加当前激活的 tab
|
||
|
||
// 切片功能相关变量
|
||
let clipStartTime = 0;
|
||
let clipEndTime = 0;
|
||
let clipTitle = "";
|
||
let clipping = false;
|
||
let current_clip_event_id = null;
|
||
let show_detail = false; // 控制快捷键说明的展开
|
||
let lastVideoId = -1; // 记录上一个视频ID,避免重复初始化
|
||
let clipTimesSet = false; // 标记用户是否主动设置过切片时间
|
||
|
||
// 进度条拖动相关变量
|
||
let isDraggingSeekbar = false;
|
||
let seekbarElement: HTMLElement;
|
||
let previewTime = 0; // 拖动时预览的时间
|
||
let wasPlayingBeforeDrag = false; // 拖动前的播放状态
|
||
|
||
// 投稿相关变量
|
||
let current_post_event_id = null;
|
||
let config: Config = null;
|
||
let accounts: any[] = [];
|
||
let uid_selected = 0;
|
||
let show_cover_editor = false;
|
||
|
||
// 获取 profile 从 localStorage
|
||
function get_profile(): Profile {
|
||
const profile_str = window.localStorage.getItem("profile-" + roomId);
|
||
if (profile_str && profile_str.includes("videos")) {
|
||
return JSON.parse(profile_str);
|
||
}
|
||
return default_profile();
|
||
}
|
||
|
||
let profile: Profile = get_profile();
|
||
|
||
$: {
|
||
window.localStorage.setItem("profile-" + roomId, JSON.stringify(profile));
|
||
}
|
||
|
||
// on window close, save subtitles
|
||
onMount(async () => {
|
||
if (TAURI_ENV) {
|
||
// 使用 Tauri 的全局事件监听器
|
||
try {
|
||
windowCloseUnlisten = await tauriListen(
|
||
"tauri://close-requested",
|
||
async () => {
|
||
await saveSubtitles();
|
||
}
|
||
);
|
||
} catch (error) {
|
||
log.warn("Failed to listen to window close event:", error);
|
||
}
|
||
} else {
|
||
// 在非 Tauri 环境中使用 beforeunload
|
||
window.addEventListener("beforeunload", async () => {
|
||
await saveSubtitles();
|
||
});
|
||
}
|
||
|
||
// 初始化投稿相关数据
|
||
try {
|
||
// 获取配置
|
||
config = (await invoke("get_config")) as Config;
|
||
|
||
// 获取账号列表
|
||
const account_info: AccountInfo = await invoke("get_accounts");
|
||
accounts = account_info.accounts
|
||
.filter((a) => a.platform === "bilibili")
|
||
.map((a) => ({
|
||
value: a.uid,
|
||
name: a.name,
|
||
platform: a.platform,
|
||
}));
|
||
} catch (error) {
|
||
console.error("Failed to initialize upload data:", error);
|
||
}
|
||
});
|
||
|
||
onDestroy(() => {
|
||
// 清理窗口关闭事件监听器
|
||
if (windowCloseUnlisten) {
|
||
windowCloseUnlisten();
|
||
}
|
||
});
|
||
|
||
function update_encode_prompt(content: string) {
|
||
const encode_prompt = document.getElementById("encode-prompt");
|
||
if (encode_prompt) {
|
||
encode_prompt.textContent = content;
|
||
}
|
||
}
|
||
|
||
function update_generate_prompt(content: string) {
|
||
const generate_prompt = document.getElementById("generate-prompt");
|
||
if (generate_prompt) {
|
||
generate_prompt.textContent = content;
|
||
}
|
||
}
|
||
|
||
function update_post_prompt(str: string) {
|
||
const span = document.getElementById("post-prompt");
|
||
if (span) {
|
||
span.textContent = str;
|
||
}
|
||
}
|
||
|
||
// 投稿相关函数
|
||
async function do_post() {
|
||
if (!video) {
|
||
return;
|
||
}
|
||
|
||
let event_id = generateEventId();
|
||
current_post_event_id = event_id;
|
||
|
||
update_post_prompt(`投稿上传中`);
|
||
|
||
const clear_update_listener = await listen(
|
||
`progress-update:${event_id}`,
|
||
(e) => {
|
||
update_post_prompt(e.payload.content);
|
||
}
|
||
);
|
||
const clear_finished_listener = await listen(
|
||
`progress-finished:${event_id}`,
|
||
(e) => {
|
||
update_post_prompt(`投稿`);
|
||
if (!e.payload.success) {
|
||
alert(e.payload.message);
|
||
}
|
||
|
||
current_post_event_id = null;
|
||
|
||
clear_update_listener();
|
||
clear_finished_listener();
|
||
}
|
||
);
|
||
|
||
// update profile in local storage
|
||
window.localStorage.setItem("profile-" + roomId, JSON.stringify(profile));
|
||
invoke("upload_procedure", {
|
||
uid: uid_selected,
|
||
eventId: event_id,
|
||
roomId: roomId,
|
||
videoId: video.id,
|
||
cover: video.cover,
|
||
profile: profile,
|
||
}).then(async () => {
|
||
uid_selected = 0;
|
||
await onVideoListUpdate?.();
|
||
});
|
||
}
|
||
|
||
async function cancel_post() {
|
||
if (!current_post_event_id) {
|
||
return;
|
||
}
|
||
invoke("cancel", { eventId: current_post_event_id });
|
||
}
|
||
|
||
function pauseVideo() {
|
||
if (videoElement) {
|
||
videoElement.pause();
|
||
}
|
||
}
|
||
|
||
// 监听当前字幕索引变化
|
||
$: if (currentSubtitleIndex >= 0 && subtitleElements[currentSubtitleIndex]) {
|
||
subtitleElements[currentSubtitleIndex].scrollIntoView({
|
||
behavior: "smooth",
|
||
block: "nearest",
|
||
});
|
||
}
|
||
|
||
function parseSrtTime(time: string): number {
|
||
// hours:minutes:seconds,milliseconds
|
||
// Only replace the comma that separates seconds and milliseconds, not the arrow separator
|
||
const timeParts = time.split(",");
|
||
if (timeParts.length !== 2) {
|
||
console.warn("Invalid time format (missing comma):", time);
|
||
return 0;
|
||
}
|
||
|
||
const timeWithoutMs = timeParts[0];
|
||
const millisecondsStr = timeParts[1];
|
||
|
||
const parts = timeWithoutMs.split(":");
|
||
if (parts.length !== 3) {
|
||
console.warn("Invalid time format:", time);
|
||
return 0;
|
||
}
|
||
|
||
const [hours, minutes, seconds] = parts;
|
||
const hoursNum = parseInt(hours, 10);
|
||
const minutesNum = parseInt(minutes, 10);
|
||
const secondsNum = parseInt(seconds, 10);
|
||
|
||
// Pad milliseconds to 3 digits if needed
|
||
const millisecondsNum = parseInt(millisecondsStr.padEnd(3, "0"), 10);
|
||
|
||
if (
|
||
isNaN(hoursNum) ||
|
||
isNaN(minutesNum) ||
|
||
isNaN(secondsNum) ||
|
||
isNaN(millisecondsNum)
|
||
) {
|
||
console.warn("Invalid time values:", time);
|
||
return 0;
|
||
}
|
||
|
||
return (
|
||
hoursNum * 3600 + minutesNum * 60 + secondsNum + millisecondsNum / 1000
|
||
);
|
||
}
|
||
|
||
function formatSrtTime(time: number): string {
|
||
const hours = Math.floor(time / 3600);
|
||
const minutes = Math.floor((time % 3600) / 60);
|
||
const seconds = Math.floor(time % 60);
|
||
const milliseconds = Math.floor((time % 1) * 1000);
|
||
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")},${milliseconds.toString().padStart(3, "0")}`;
|
||
}
|
||
|
||
function srtToSubtitles(srt: string): Subtitle[] {
|
||
if (!srt.trim()) return [];
|
||
|
||
// Split by double newlines to separate subtitle blocks
|
||
const blocks = srt.split(/\n\s*\n/);
|
||
|
||
return blocks
|
||
.map((block) => {
|
||
// Split block into lines and filter out empty lines
|
||
const lines = block.split("\n").filter((line) => line.trim());
|
||
|
||
// Skip if block doesn't have enough lines
|
||
if (lines.length < 3) return null;
|
||
|
||
// Skip the first line (subtitle number)
|
||
const timeLine = lines[1];
|
||
const text = lines.slice(2).join("\n");
|
||
|
||
// Parse time line (format: "00:00:00,000 --> 00:00:00,000" or "00:00:00,000-->00:00:00,000")
|
||
const timeParts = timeLine.split(/\s*-->\s*/);
|
||
if (timeParts.length !== 2) {
|
||
console.warn("Invalid time line format:", timeLine);
|
||
return null;
|
||
}
|
||
|
||
const startTime = parseSrtTime(timeParts[0].trim());
|
||
const endTime = parseSrtTime(timeParts[1].trim());
|
||
|
||
if (isNaN(startTime) || isNaN(endTime)) {
|
||
console.warn("Failed to parse time values:", timeLine);
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
startTime,
|
||
endTime,
|
||
text,
|
||
};
|
||
})
|
||
.filter((subtitle): subtitle is Subtitle => subtitle !== null)
|
||
.sort((a, b) => a.startTime - b.startTime);
|
||
}
|
||
|
||
function subtitlesToSrt(subtitles: Subtitle[]): string {
|
||
return subtitles
|
||
.map((subtitle, index) => {
|
||
return `${index + 1}\n${formatSrtTime(subtitle.startTime)} --> ${formatSrtTime(subtitle.endTime)}\n${subtitle.text}\n`;
|
||
})
|
||
.join("\n");
|
||
}
|
||
|
||
// 保存字幕到 localStorage
|
||
async function saveSubtitles() {
|
||
if (video?.file) {
|
||
try {
|
||
console.log("update video subtitle");
|
||
await invoke("update_video_subtitle", {
|
||
id: video.id,
|
||
subtitle: subtitlesToSrt(subtitles),
|
||
});
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function generateSubtitles() {
|
||
if (video?.file) {
|
||
current_generate_event_id = generateEventId();
|
||
const clear_update_listener = await listen(
|
||
`progress-update:${current_generate_event_id}`,
|
||
(e) => {
|
||
update_generate_prompt(e.payload.content);
|
||
}
|
||
);
|
||
const clear_finished_listener = await listen(
|
||
`progress-finished:${current_generate_event_id}`,
|
||
(e) => {
|
||
update_generate_prompt(`AI 生成字幕`);
|
||
if (!e.payload.success) {
|
||
alert("生成字幕失败: " + e.payload.message);
|
||
}
|
||
|
||
current_generate_event_id = null;
|
||
|
||
clear_update_listener();
|
||
clear_finished_listener();
|
||
}
|
||
);
|
||
const savedSubtitles = (await invoke("generate_video_subtitle", {
|
||
eventId: current_generate_event_id,
|
||
id: video.id,
|
||
})) as string;
|
||
subtitles = srtToSubtitles(savedSubtitles);
|
||
}
|
||
}
|
||
|
||
// 从 localStorage 加载字幕
|
||
async function loadSubtitles() {
|
||
if (video?.file) {
|
||
const savedSubtitles = (await invoke("get_video_subtitle", {
|
||
id: video.id,
|
||
})) as string;
|
||
if (savedSubtitles) {
|
||
subtitles = srtToSubtitles(savedSubtitles);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 加载字幕样式
|
||
function loadSubtitleStyle() {
|
||
const savedStyle = localStorage.getItem(`subtitle_style_${roomId}`);
|
||
if (savedStyle) {
|
||
subtitleStyle = JSON.parse(savedStyle);
|
||
}
|
||
}
|
||
|
||
$: if (show) {
|
||
isVideoLoaded = false;
|
||
subtitles = []; // 清空字幕列表
|
||
currentSubtitleIndex = -1;
|
||
subtitleElements = [];
|
||
loadSubtitleStyle(); // 加载字幕样式
|
||
}
|
||
|
||
// 当视频改变时重新初始化切片时间(只在视频ID改变时触发)
|
||
$: if (video && videoElement?.duration && video.id !== lastVideoId) {
|
||
lastVideoId = video.id;
|
||
// 切换视频时重置切片时间 - 不设置默认值,等待用户输入
|
||
clipStartTime = 0;
|
||
clipEndTime = 0;
|
||
clipTitle = "";
|
||
clipTimesSet = false; // 重置标记,新视频默认透明
|
||
}
|
||
|
||
// 监听样式编辑器关闭,重新加载样式
|
||
$: if (!showStyleEditor) {
|
||
loadSubtitleStyle();
|
||
}
|
||
|
||
async function handleVideoLoaded() {
|
||
isVideoLoaded = true;
|
||
if (videoElement) {
|
||
videoElement.currentTime = 0;
|
||
videoElement.pause();
|
||
videoElement.volume = volume;
|
||
isPlaying = false;
|
||
currentTime = 0;
|
||
currentSubtitle = "";
|
||
currentSubtitleIndex = -1;
|
||
// 获取视频实际尺寸
|
||
videoWidth = videoElement.videoWidth;
|
||
videoHeight = videoElement.videoHeight;
|
||
}
|
||
await loadSubtitles(); // 加载保存的字幕
|
||
initClipTimes(); // 初始化切片时间
|
||
}
|
||
|
||
function updateTimeMarkers() {
|
||
if (!isVideoLoaded || !videoElement?.duration || !timelineWidth) {
|
||
timeMarkers = [];
|
||
return;
|
||
}
|
||
|
||
const duration = videoElement.duration;
|
||
const minMarkerWidth = 100; // 最小标记宽度(像素)
|
||
const maxMarkers = Math.floor(timelineWidth / minMarkerWidth);
|
||
const interval = Math.ceil(duration / maxMarkers);
|
||
|
||
timeMarkers = Array.from(
|
||
{ length: Math.min(Math.ceil(duration / interval) + 1, maxMarkers) },
|
||
(_, i) => Math.min(i * interval, duration)
|
||
);
|
||
}
|
||
|
||
function formatTime(seconds: number): string {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
return `${minutes}:${remainingSeconds.toFixed(1).padStart(4, "0")}`;
|
||
}
|
||
|
||
// 切片功能相关函数
|
||
function initClipTimes() {
|
||
// 不做任何自动初始化,完全等待用户输入
|
||
// 只初始化标题
|
||
if (!clipTitle) {
|
||
clipTitle = "";
|
||
}
|
||
}
|
||
|
||
function setClipStartTime() {
|
||
if (videoElement) {
|
||
const newStartTime = videoElement.currentTime;
|
||
|
||
// 如果没有选区(首次设置起点),自动将终点设置为视频结尾
|
||
if (!clipTimesSet || clipEndTime === 0) {
|
||
clipStartTime = newStartTime;
|
||
clipEndTime = videoElement.duration; // 自动设置为视频结尾
|
||
}
|
||
// 如果新的开始时间在现有结束时间之后,自动设置终点为视频结尾
|
||
else if (clipTimesSet && clipEndTime > 0 && newStartTime >= clipEndTime) {
|
||
clipStartTime = newStartTime;
|
||
clipEndTime = videoElement.duration; // 自动设置为视频结尾
|
||
} else {
|
||
clipStartTime = newStartTime;
|
||
}
|
||
|
||
clipTimesSet = true; // 标记用户已设置切片时间
|
||
}
|
||
}
|
||
|
||
function setClipEndTime() {
|
||
if (videoElement) {
|
||
const newEndTime = videoElement.currentTime;
|
||
|
||
// 如果没有选区(首次设置终点),自动将起点设置为视频开头
|
||
if (!clipTimesSet || clipStartTime === 0) {
|
||
clipStartTime = 0; // 自动设置为视频开头
|
||
clipEndTime = newEndTime;
|
||
}
|
||
// 如果新的结束时间在现有开始时间之前,清空选区重新开始
|
||
else if (
|
||
clipTimesSet &&
|
||
clipStartTime > 0 &&
|
||
newEndTime <= clipStartTime
|
||
) {
|
||
clipStartTime = 0; // 清空开始时间
|
||
clipEndTime = newEndTime;
|
||
} else {
|
||
clipEndTime = newEndTime;
|
||
}
|
||
|
||
clipTimesSet = true; // 标记用户已设置切片时间
|
||
}
|
||
}
|
||
|
||
function seekToClipStart() {
|
||
if (videoElement) {
|
||
videoElement.currentTime = clipStartTime;
|
||
}
|
||
}
|
||
|
||
function seekToClipEnd() {
|
||
if (videoElement) {
|
||
videoElement.currentTime = clipEndTime;
|
||
}
|
||
}
|
||
|
||
function clearClipSelection() {
|
||
clipStartTime = 0;
|
||
clipEndTime = 0;
|
||
clipTimesSet = false; // 重置标记,恢复透明状态
|
||
}
|
||
|
||
async function generateClip() {
|
||
if (!video) return;
|
||
|
||
// 如果没有设置切片标题,则以当前本地时间戳命名
|
||
if (!clipTitle.trim()) {
|
||
const now = new Date();
|
||
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;
|
||
}
|
||
|
||
if (clipEndTime - clipStartTime < 1) {
|
||
alert("切片长度不能少于1秒");
|
||
return;
|
||
}
|
||
|
||
clipping = true;
|
||
current_clip_event_id = generateEventId();
|
||
const clear_update_listener = await listen(
|
||
`progress-update:${current_clip_event_id}`,
|
||
(e) => {
|
||
update_clip_prompt(e.payload.content);
|
||
}
|
||
);
|
||
const clear_finished_listener = await listen(
|
||
`progress-finished:${current_clip_event_id}`,
|
||
(e) => {
|
||
update_clip_prompt(`生成切片`);
|
||
if (e.payload.success) {
|
||
// 切片生成成功,刷新视频列表
|
||
if (onVideoListUpdate) {
|
||
onVideoListUpdate();
|
||
}
|
||
// 重置切片设置
|
||
clipStartTime = 0;
|
||
clipEndTime = 0;
|
||
clipTitle = "";
|
||
clipTimesSet = false; // 重置标记
|
||
} else {
|
||
alert("切片生成失败: " + e.payload.message);
|
||
}
|
||
clipping = false;
|
||
|
||
current_clip_event_id = null;
|
||
|
||
clear_update_listener();
|
||
clear_finished_listener();
|
||
}
|
||
);
|
||
|
||
try {
|
||
await invoke("clip_video", {
|
||
eventId: current_clip_event_id,
|
||
parentVideoId: video.id,
|
||
startTime: clipStartTime,
|
||
endTime: clipEndTime,
|
||
clipTitle: clipTitle,
|
||
});
|
||
} catch (error) {
|
||
console.error("切片失败:", error);
|
||
alert("切片失败: " + error);
|
||
clipping = false;
|
||
current_clip_event_id = null;
|
||
}
|
||
}
|
||
|
||
function update_clip_prompt(text: string) {
|
||
let span = document.getElementById("generate-clip-prompt");
|
||
if (span) {
|
||
span.textContent = text;
|
||
}
|
||
}
|
||
|
||
function canBeClipped(video: VideoItem): boolean {
|
||
if (!video) {
|
||
return false;
|
||
}
|
||
|
||
// 只要不是正在录制的视频(status !== -1),都可以切片
|
||
// 这包括:
|
||
// - 导入的视频 (imported)
|
||
// - 所有平台的切片 (clip, bilibili_clip, douyin_clip等)
|
||
// - 录制完成的视频 (status === 0 或 status === 1)
|
||
return video.status !== -1;
|
||
}
|
||
|
||
// 键盘快捷键处理
|
||
function handleKeydown(event: KeyboardEvent) {
|
||
if (!show || !isVideoLoaded) return;
|
||
|
||
// 如果在输入框中,不处理某些快捷键
|
||
const isInInput = (event.target as HTMLElement)?.tagName === "INPUT";
|
||
|
||
switch (event.key) {
|
||
case "【":
|
||
case "[":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
setClipStartTime();
|
||
}
|
||
break;
|
||
case "】":
|
||
case "]":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
setClipEndTime();
|
||
}
|
||
break;
|
||
case "q":
|
||
case "Q":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
seekToClipStart();
|
||
}
|
||
break;
|
||
case "e":
|
||
case "E":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
seekToClipEnd();
|
||
}
|
||
break;
|
||
case " ":
|
||
if (!isInInput) {
|
||
event.preventDefault();
|
||
togglePlay();
|
||
}
|
||
break;
|
||
case "ArrowLeft":
|
||
if (!isInInput) {
|
||
event.preventDefault();
|
||
if (videoElement) {
|
||
videoElement.currentTime = Math.max(
|
||
0,
|
||
videoElement.currentTime - 5
|
||
);
|
||
}
|
||
}
|
||
break;
|
||
case "ArrowRight":
|
||
if (!isInInput) {
|
||
event.preventDefault();
|
||
if (videoElement) {
|
||
videoElement.currentTime = Math.min(
|
||
videoElement.duration,
|
||
videoElement.currentTime + 5
|
||
);
|
||
}
|
||
}
|
||
break;
|
||
case "g":
|
||
case "G":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
generateClip();
|
||
}
|
||
break;
|
||
case "c":
|
||
case "C":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
clearClipSelection();
|
||
}
|
||
break;
|
||
case "h":
|
||
case "H":
|
||
if (!isInInput && canBeClipped(video)) {
|
||
event.preventDefault();
|
||
show_detail = !show_detail;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
function togglePlay() {
|
||
if (isPlaying) {
|
||
videoElement.pause();
|
||
} else {
|
||
videoElement.play();
|
||
}
|
||
isPlaying = !isPlaying;
|
||
}
|
||
|
||
function handleTimeUpdate() {
|
||
currentTime = videoElement.currentTime;
|
||
// Find current subtitle
|
||
currentSubtitleIndex = getCurrentSubtitleIndex();
|
||
const currentSub = subtitles[currentSubtitleIndex];
|
||
currentSubtitle = currentSub?.text || "";
|
||
}
|
||
|
||
function handleVideoEnded() {
|
||
isPlaying = false;
|
||
}
|
||
|
||
function handleTimelineClick(e: MouseEvent) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// 如果正在拖动进度条,不处理点击事件
|
||
if (isDraggingSeekbar) return;
|
||
|
||
if (!timelineElement || !videoElement) return;
|
||
const rect = timelineElement.getBoundingClientRect();
|
||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||
// 直接使用容器的实际宽度计算时间,与 getSubtitleStyle 逻辑保持一致
|
||
const time = (x / rect.width) * videoElement.duration;
|
||
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);
|
||
}
|
||
|
||
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;
|
||
previewTime = newTime;
|
||
}
|
||
|
||
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();
|
||
isPlaying = true;
|
||
}
|
||
}
|
||
|
||
function addSubtitle() {
|
||
const newStartTime = currentTime;
|
||
const newEndTime = Math.min(currentTime + 5, videoElement.duration);
|
||
subtitles = [
|
||
...subtitles,
|
||
{
|
||
startTime: newStartTime,
|
||
endTime: newEndTime,
|
||
text: "",
|
||
},
|
||
];
|
||
subtitles.sort((a, b) => a.startTime - b.startTime);
|
||
}
|
||
|
||
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)
|
||
);
|
||
return { ...sub, endTime: newEndTime };
|
||
}
|
||
});
|
||
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
|
||
}
|
||
|
||
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 finalEndTime = finalStartTime + duration;
|
||
|
||
subtitles = subtitles.map((s, i) =>
|
||
i === index
|
||
? { ...s, startTime: finalStartTime, endTime: finalEndTime }
|
||
: s
|
||
);
|
||
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
|
||
}
|
||
|
||
async function removeSubtitle(index: number) {
|
||
subtitles = subtitles.filter((_, i) => i !== index);
|
||
await saveSubtitles(); // 删除字幕时保存
|
||
}
|
||
|
||
async function clearSubtitles() {
|
||
subtitles = [];
|
||
await saveSubtitles(); // 清空字幕时保存
|
||
}
|
||
|
||
function seekToTime(time: number) {
|
||
videoElement.currentTime = time;
|
||
}
|
||
|
||
function adjustTime(index: number, isStart: boolean, delta: number) {
|
||
const sub = subtitles[index];
|
||
if (isStart) {
|
||
const newTime = Math.max(0, sub.startTime + delta);
|
||
if (newTime < sub.endTime - 0.1) {
|
||
subtitles = subtitles.map((s, i) =>
|
||
i === index ? { ...s, startTime: newTime } : s
|
||
);
|
||
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
|
||
}
|
||
} else {
|
||
const newTime = Math.min(videoElement.duration, sub.endTime + delta);
|
||
if (newTime > sub.startTime + 0.1) {
|
||
subtitles = subtitles.map((s, i) =>
|
||
i === index ? { ...s, endTime: newTime } : s
|
||
);
|
||
subtitles = subtitles.sort((a, b) => a.startTime - b.startTime);
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleTimelineMouseDown(
|
||
e: MouseEvent,
|
||
index: number,
|
||
isStart: boolean
|
||
) {
|
||
startEdgeDragging(index, isStart);
|
||
}
|
||
|
||
// 辅助函数:统一开始边缘拖拽
|
||
function startEdgeDragging(index: number, isStart: boolean) {
|
||
draggingSubtitle = { index, isStart };
|
||
document.addEventListener("mousemove", handleTimelineMouseMove);
|
||
document.addEventListener("mouseup", handleTimelineMouseUp);
|
||
}
|
||
|
||
// 辅助函数:统一开始块拖拽
|
||
function startBlockDragging(
|
||
index: number,
|
||
mouseTime: number,
|
||
startTime: number
|
||
) {
|
||
draggingBlock = index;
|
||
dragOffset = mouseTime - startTime;
|
||
document.addEventListener("mousemove", handleBlockMouseMove);
|
||
document.addEventListener("mouseup", handleBlockMouseUp);
|
||
}
|
||
|
||
function handleBlockMouseDown(e: MouseEvent, index: number) {
|
||
const sub = subtitles[index];
|
||
const rect = timelineElement.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
const mouseTime = (x / rect.width) * 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) {
|
||
// 左边缘:调整开始时间
|
||
startEdgeDragging(index, true);
|
||
} else if (blockWidth > edgeSize * 2 && relativeX > blockWidth - edgeSize) {
|
||
// 右边缘:调整结束时间(仅当有足够空间时)
|
||
startEdgeDragging(index, false);
|
||
} else if (blockWidth <= edgeSize * 2 && relativeX > edgeSize) {
|
||
// 短字幕右侧:调整结束时间
|
||
startEdgeDragging(index, false);
|
||
} else {
|
||
// 中间区域:移动整个字幕
|
||
startBlockDragging(index, mouseTime, sub.startTime);
|
||
}
|
||
}
|
||
|
||
function handleTimelineMouseMove(e: MouseEvent) {
|
||
if (!draggingSubtitle || !timelineElement) return;
|
||
|
||
const rect = timelineElement.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
// 直接使用容器的实际宽度计算时间,与 getSubtitleStyle 逻辑保持一致
|
||
const time = (x / rect.width) * videoElement.duration;
|
||
|
||
updateSubtitleTime(draggingSubtitle.index, draggingSubtitle.isStart, time);
|
||
}
|
||
|
||
function handleBlockMouseMove(e: MouseEvent) {
|
||
if (draggingBlock === null || !timelineElement) return;
|
||
|
||
const rect = timelineElement.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
// 直接使用容器的实际宽度计算时间,与 getSubtitleStyle 逻辑保持一致
|
||
const mouseTime = (x / rect.width) * videoElement.duration;
|
||
const newStartTime = mouseTime - dragOffset; // 使用偏移量计算新的开始时间
|
||
|
||
moveSubtitle(draggingBlock, newStartTime);
|
||
}
|
||
|
||
function handleTimelineMouseUp() {
|
||
draggingSubtitle = null;
|
||
document.removeEventListener("mousemove", handleTimelineMouseMove);
|
||
document.removeEventListener("mouseup", handleTimelineMouseUp);
|
||
}
|
||
|
||
function handleBlockMouseUp() {
|
||
draggingBlock = null;
|
||
document.removeEventListener("mousemove", handleBlockMouseMove);
|
||
document.removeEventListener("mouseup", handleBlockMouseUp);
|
||
}
|
||
|
||
function getSubtitleStyle(subtitle: Subtitle) {
|
||
if (!isVideoLoaded || !videoElement?.duration) return "";
|
||
// 字幕块位置应该是相对于时间轴容器的百分比,不需要乘以缩放因子
|
||
// 因为容器本身已经通过 style="width: {100 * timelineScale}%" 进行了缩放
|
||
const start = (subtitle.startTime / videoElement.duration) * 100;
|
||
const width =
|
||
((subtitle.endTime - subtitle.startTime) / videoElement.duration) * 100;
|
||
return `left: ${start}%; width: ${width}%;`;
|
||
}
|
||
|
||
function handleVolumeChange(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
volume = parseFloat(input.value);
|
||
if (videoElement) {
|
||
videoElement.volume = volume;
|
||
}
|
||
}
|
||
|
||
function handleCoverError(event: Event) {
|
||
console.error("Cover image load failed:", event);
|
||
showDefaultCoverIcon = true;
|
||
}
|
||
|
||
function toggleMute() {
|
||
if (videoElement) {
|
||
if (isMuted) {
|
||
videoElement.volume = previousVolume;
|
||
} else {
|
||
previousVolume = videoElement.volume;
|
||
videoElement.volume = 0;
|
||
}
|
||
isMuted = !isMuted;
|
||
}
|
||
}
|
||
|
||
function getCurrentSubtitleIndex(): number {
|
||
return subtitles.findIndex(
|
||
(sub) => currentTime >= sub.startTime && currentTime < sub.endTime
|
||
);
|
||
}
|
||
|
||
function handleScaleChange(e: Event) {
|
||
const input = e.target as HTMLInputElement;
|
||
timelineScale = parseFloat(input.value);
|
||
const rect = timelineElement.getBoundingClientRect();
|
||
timelineWidth = rect.width;
|
||
updateTimeMarkers();
|
||
}
|
||
|
||
function handleWheel(e: WheelEvent) {
|
||
e.preventDefault();
|
||
if (timelineContainer) {
|
||
timelineContainer.scrollLeft += e.deltaY;
|
||
}
|
||
}
|
||
|
||
async function encodeVideoSubtitle() {
|
||
await saveSubtitles();
|
||
const event_id = generateEventId();
|
||
current_encode_event_id = event_id;
|
||
const clear_update_listener = await listen(
|
||
`progress-update:${event_id}`,
|
||
(e) => {
|
||
update_encode_prompt(e.payload.content);
|
||
}
|
||
);
|
||
const clear_finished_listener = await listen(
|
||
`progress-finished:${event_id}`,
|
||
(e) => {
|
||
update_encode_prompt(`压制字幕`);
|
||
if (!e.payload.success) {
|
||
alert("压制失败: " + e.payload.message);
|
||
}
|
||
|
||
current_encode_event_id = null;
|
||
|
||
clear_update_listener();
|
||
clear_finished_listener();
|
||
}
|
||
);
|
||
const result = await invoke("encode_video_subtitle", {
|
||
eventId: event_id,
|
||
id: video.id,
|
||
srtStyle: parseSubtitleStyle(subtitleStyle),
|
||
});
|
||
console.log(result);
|
||
// 压制成功后更新视频列表
|
||
await onVideoListUpdate?.();
|
||
}
|
||
|
||
function handleVideoSelect(e: Event) {
|
||
const selectedVideo = videos.find(
|
||
(v) => v.id === Number((e.target as HTMLSelectElement).value)
|
||
);
|
||
if (selectedVideo) {
|
||
// 清空字幕列表
|
||
subtitles = [];
|
||
currentSubtitleIndex = -1;
|
||
currentSubtitle = "";
|
||
// 重置视频状态
|
||
if (videoElement) {
|
||
videoElement.currentTime = 0;
|
||
videoElement.pause();
|
||
isPlaying = false;
|
||
currentTime = 0;
|
||
}
|
||
// 调用父组件的回调
|
||
onVideoChange?.(selectedVideo);
|
||
}
|
||
}
|
||
|
||
async function saveVideo() {
|
||
if (!video) return;
|
||
const video_url = video.file;
|
||
const video_name = video.file;
|
||
const a = document.createElement("a");
|
||
a.href = video_url;
|
||
a.download = video_name;
|
||
a.click();
|
||
}
|
||
</script>
|
||
|
||
{#if show}
|
||
<div
|
||
class="fixed inset-0 bg-[#1c1c1e] z-[1000] transition-opacity duration-200"
|
||
class:opacity-0={!show}
|
||
class:opacity-100={show}
|
||
>
|
||
<!-- 顶部导航栏 -->
|
||
<div
|
||
class="h-14 border-b border-gray-800/50 bg-[#2c2c2e] flex items-center px-4 justify-between"
|
||
>
|
||
<div class="flex items-center space-x-4">
|
||
<!-- 视频选择器 -->
|
||
<div class="relative flex items-center space-x-2">
|
||
<select
|
||
class="bg-[#1c1c1e] text-gray-300 text-sm rounded-md px-3 py-1.5 border border-gray-700 focus:border-[#0A84FF] outline-none appearance-none cursor-pointer hover:bg-[#2c2c2e] transition-colors duration-200"
|
||
value={video.id}
|
||
on:change={handleVideoSelect}
|
||
>
|
||
{#each videos as v}
|
||
<option value={v.id}>{v.name}</option>
|
||
{/each}
|
||
</select>
|
||
<!-- 保存按钮 -->
|
||
{#if !TAURI_ENV}
|
||
<button
|
||
class="text-blue-500 hover:text-blue-400 transition-colors duration-200 px-2 py-1.5 rounded-md hover:bg-blue-500/10"
|
||
on:click={saveVideo}
|
||
>
|
||
<Download class="w-4 h-4" />
|
||
</button>
|
||
{/if}
|
||
<!-- 删除按钮 -->
|
||
<button
|
||
class="text-red-500 hover:text-red-400 transition-colors duration-200 px-2 py-1.5 rounded-md hover:bg-red-500/10"
|
||
on:click={async () => {
|
||
if (!video) return;
|
||
try {
|
||
await invoke("delete_video", { id: video.id });
|
||
// 更新视频列表
|
||
await onVideoListUpdate?.();
|
||
// 如果列表不为空,选择新的视频
|
||
if (videos.length > 0) {
|
||
const newVideo = videos[0];
|
||
onVideoChange?.(newVideo);
|
||
} else {
|
||
// 如果列表为空,关闭窗口
|
||
close_window();
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
alert("删除失败:" + error);
|
||
}
|
||
}}
|
||
>
|
||
<Trash2 class="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center space-x-2">
|
||
{#if canBeClipped(video)}
|
||
<button
|
||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-600/90 transition-colors duration-200 border border-gray-600/50 flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
on:click={generateClip}
|
||
disabled={clipping || current_clip_event_id != null}
|
||
>
|
||
{#if clipping || current_clip_event_id != null}
|
||
<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>
|
||
{:else}
|
||
<Scissors class="w-4 h-4" />
|
||
{/if}
|
||
<span id="generate-clip-prompt"
|
||
>{clipping ? "生成中..." : "生成切片"}</span
|
||
>
|
||
</button>
|
||
{/if}
|
||
<button
|
||
class="px-4 py-1.5 text-sm bg-[#0A84FF] text-white rounded-md hover:bg-[#0A84FF]/90 transition-colors duration-200 border border-gray-600/50 flex items-center space-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
on:click={() => (showEncodeModal = true)}
|
||
disabled={current_encode_event_id != null}
|
||
>
|
||
{#if current_encode_event_id != null}
|
||
<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 714 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
></path>
|
||
</svg>
|
||
{:else}
|
||
<Film class="w-4 h-4" />
|
||
{/if}
|
||
<span id="encode-prompt">压制字幕</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 编码确认 Modal -->
|
||
{#if showEncodeModal}
|
||
<div
|
||
class="fixed inset-0 bg-black/50 z-[1100] flex items-center justify-center"
|
||
>
|
||
<div class="bg-[#2c2c2e] rounded-lg shadow-xl w-[480px] max-w-[90vw]">
|
||
<!-- Modal 头部 -->
|
||
<div
|
||
class="px-4 py-3 border-b border-gray-800/50 flex items-center justify-between"
|
||
>
|
||
<h3 class="text-sm font-medium text-gray-200">确认压制</h3>
|
||
<button
|
||
class="text-gray-400 hover:text-white transition-colors duration-200"
|
||
on:click={() => (showEncodeModal = false)}
|
||
>
|
||
<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"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Modal 内容 -->
|
||
<div class="p-4 text-gray-300">
|
||
压制需要耗费一定的时间,请耐心等待;压制完成后,切片列表中会出现新的带有字幕的切片。确定要进行压制吗?
|
||
</div>
|
||
|
||
<!-- Modal 底部按钮 -->
|
||
<div
|
||
class="px-4 py-3 border-t border-gray-800/50 flex justify-end space-x-2"
|
||
>
|
||
<button
|
||
class="px-4 py-1.5 text-sm bg-gray-700/50 text-gray-200 rounded-md hover:bg-gray-700/70 transition-colors duration-200 border border-gray-600/50"
|
||
on:click={() => (showEncodeModal = false)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
class="px-4 py-1.5 text-sm bg-[#0A84FF] text-white rounded-md hover:bg-[#0A84FF]/90 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||
on:click={() => {
|
||
showEncodeModal = false;
|
||
encodeVideoSubtitle();
|
||
}}
|
||
>
|
||
<span>确认</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="flex h-[calc(100vh-3.5rem)]">
|
||
<!-- 视频区域 -->
|
||
<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="flex items-center space-x-6">
|
||
<div class="text-gray-300">
|
||
切片起点: <span class="text-[#0A84FF] font-mono"
|
||
>{formatTime(clipStartTime)}</span
|
||
>
|
||
</div>
|
||
<div class="text-gray-300">
|
||
切片终点: <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
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
<!-- 视频容器 -->
|
||
<div class="flex-1 bg-black relative">
|
||
<div class="absolute inset-0 flex items-center">
|
||
<!-- svelte-ignore a11y-media-has-caption -->
|
||
<video
|
||
bind:this={videoElement}
|
||
src={video?.file}
|
||
class="w-full h-auto max-h-full cursor-pointer"
|
||
on:timeupdate={handleTimeUpdate}
|
||
on:ended={handleVideoEnded}
|
||
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;"
|
||
>
|
||
<p style="margin: 0;">
|
||
快捷键说明
|
||
<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>
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
<!-- 字幕显示 -->
|
||
{#if currentSubtitle}
|
||
<div
|
||
class="absolute bottom-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-white"
|
||
style="
|
||
font-family: {subtitleStyle.fontName};
|
||
font-size: {videoHeight * (subtitleStyle.fontSize / 720)}px;
|
||
color: {subtitleStyle.fontColor};
|
||
text-shadow: {`
|
||
${subtitleStyle.outlineWidth}px ${subtitleStyle.outlineWidth}px 0 ${subtitleStyle.outlineColor},
|
||
-${subtitleStyle.outlineWidth}px ${subtitleStyle.outlineWidth}px 0 ${subtitleStyle.outlineColor},
|
||
${subtitleStyle.outlineWidth}px -${subtitleStyle.outlineWidth}px 0 ${subtitleStyle.outlineColor},
|
||
-${subtitleStyle.outlineWidth}px -${subtitleStyle.outlineWidth}px 0 ${subtitleStyle.outlineColor}
|
||
`};
|
||
text-align: {subtitleStyle.alignment === 1
|
||
? 'left'
|
||
: subtitleStyle.alignment === 2
|
||
? 'center'
|
||
: 'right'};
|
||
margin-bottom: {videoHeight *
|
||
(subtitleStyle.marginV / 720)}px;
|
||
"
|
||
>
|
||
{currentSubtitle}
|
||
</div>
|
||
{/if}
|
||
</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="flex items-center space-x-2">
|
||
<!-- 缩放控制 -->
|
||
<span class="text-xs text-gray-400">缩放</span>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="10"
|
||
step="0.1"
|
||
value={timelineScale}
|
||
on:input={handleScaleChange}
|
||
class="w-32 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 中间播放控制 -->
|
||
<div class="flex-1 flex justify-center">
|
||
<button
|
||
class="p-1.5 rounded-lg bg-[#2c2c2e] hover:bg-[#3c3c3e] transition-colors duration-200"
|
||
on:click={togglePlay}
|
||
>
|
||
{#if isPlaying}
|
||
<Pause class="w-4 h-4 text-white" />
|
||
{:else}
|
||
<Play class="w-4 h-4 text-white" />
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 音量控制 -->
|
||
<div class="flex items-center space-x-2">
|
||
<button
|
||
class="text-white hover:text-gray-300 transition-colors duration-200"
|
||
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"
|
||
>
|
||
<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"
|
||
>
|
||
<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"
|
||
>
|
||
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
||
</svg>
|
||
{/if}
|
||
</button>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.1"
|
||
bind:value={volume}
|
||
on:input={handleVolumeChange}
|
||
class="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 字幕时间轴 -->
|
||
<div class="bg-[#1c1c1e] border-t border-gray-800/50">
|
||
<div
|
||
class="h-32 overflow-x-auto overflow-y-hidden sidebar-scrollbar"
|
||
bind:this={timelineContainer}
|
||
on:wheel|preventDefault={handleWheel}
|
||
>
|
||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||
<div
|
||
bind:this={timelineElement}
|
||
class="relative h-full group"
|
||
style="width: {100 * timelineScale}%"
|
||
on:mousemove={(e) => {
|
||
if (!timelineElement) return;
|
||
const rect = timelineElement.getBoundingClientRect();
|
||
timelineWidth = rect.width;
|
||
updateTimeMarkers();
|
||
}}
|
||
on:click|preventDefault|stopPropagation={(e) => {
|
||
// 只有在不拖动进度条时才处理时间轴点击
|
||
if (!isDraggingSeekbar) {
|
||
handleTimelineClick(e);
|
||
}
|
||
}}
|
||
>
|
||
<!-- 切片选区可视化 -->
|
||
{#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 h-full bg-green-400/80 transition-all duration-200"
|
||
style="left: {(clipStartTime /
|
||
(videoElement?.duration || 1)) *
|
||
100}%; right: {100 -
|
||
(clipEndTime / (videoElement?.duration || 1)) * 100}%"
|
||
></div>
|
||
<!-- 切片起点标记 -->
|
||
<div
|
||
class="absolute h-full w-0.5 bg-green-500 transition-all duration-200"
|
||
style="left: {(clipStartTime /
|
||
(videoElement?.duration || 1)) *
|
||
100}%"
|
||
></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%)"
|
||
></div>
|
||
</div>
|
||
{/if}
|
||
<!-- 播放进度条容器 (借鉴Shaka Player样式) -->
|
||
<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}
|
||
on:mousedown={handleSeekbarMouseDown}
|
||
>
|
||
<!-- 播放进度条 -->
|
||
<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>
|
||
|
||
<!-- 播放进度条滑块 (hover或拖动时显示) -->
|
||
<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)"
|
||
></div>
|
||
</div>
|
||
|
||
<!-- 时间刻度 -->
|
||
{#each timeMarkers as time}
|
||
<div
|
||
class="absolute top-2 bottom-0 border-l border-gray-700"
|
||
style="left: {(time / (videoElement?.duration || 1)) * 100}%"
|
||
>
|
||
<div
|
||
class="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-gray-400 whitespace-nowrap"
|
||
>
|
||
{formatTime(time)}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
|
||
<!-- 字幕块 -->
|
||
{#each subtitles as subtitle, index}
|
||
<div
|
||
bind:this={subtitleElements[index]}
|
||
class="absolute top-6 bottom-6 bg-[#0A84FF]/30 rounded-lg cursor-move"
|
||
style={getSubtitleStyle(subtitle)}
|
||
on:mousedown={(e) => handleBlockMouseDown(e, index)}
|
||
>
|
||
<!-- 开始时间手柄 -->
|
||
<div
|
||
class="absolute left-0 top-0 bottom-0 w-1 bg-[#0A84FF] rounded-l cursor-ew-resize"
|
||
on:mousedown|stopPropagation={(e) =>
|
||
handleTimelineMouseDown(e, index, true)}
|
||
/>
|
||
<!-- 结束时间手柄 -->
|
||
<div
|
||
class="absolute right-0 top-0 bottom-0 w-1 bg-[#0A84FF] rounded-r cursor-ew-resize"
|
||
on:mousedown|stopPropagation={(e) =>
|
||
handleTimelineMouseDown(e, index, false)}
|
||
/>
|
||
<!-- 字幕文本预览 -->
|
||
<div
|
||
class="absolute inset-x-2 inset-y-1 flex items-center justify-center text-xs text-white text-center line-clamp-3 rounded"
|
||
>
|
||
{subtitle.text || "空字幕"}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 字幕编辑面板 -->
|
||
<div
|
||
class="w-80 border-l border-gray-800/50 bg-[#2c2c2e] overflow-y-auto sidebar-scrollbar"
|
||
>
|
||
<!-- Tab 导航 -->
|
||
<div class="flex border-b border-gray-800/50 bg-[#1c1c1e]">
|
||
<button
|
||
class="px-6 py-3 text-sm font-medium transition-all duration-200 relative"
|
||
class:text-white={activeTab === "subtitle"}
|
||
class:text-gray-400={activeTab !== "subtitle"}
|
||
class:bg-[#2c2c2e]={activeTab === "subtitle"}
|
||
class:bg-transparent={activeTab !== "subtitle"}
|
||
on:click={() => (activeTab = "subtitle")}
|
||
>
|
||
字幕
|
||
{#if activeTab === "subtitle"}
|
||
<div
|
||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-[#0A84FF]"
|
||
></div>
|
||
{/if}
|
||
</button>
|
||
|
||
<button
|
||
class="px-6 py-3 text-sm font-medium transition-all duration-200 relative"
|
||
class:text-white={activeTab === "upload"}
|
||
class:text-gray-400={activeTab !== "upload"}
|
||
class:bg-[#2c2c2e]={activeTab === "upload"}
|
||
class:bg-transparent={activeTab !== "upload"}
|
||
on:click={() => (activeTab = "upload")}
|
||
>
|
||
快速投稿
|
||
{#if activeTab === "upload"}
|
||
<div
|
||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-[#0A84FF]"
|
||
></div>
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Tab 内容 -->
|
||
{#if activeTab === "subtitle"}
|
||
<!-- 字幕 Tab 内容 -->
|
||
<div class="p-4 space-y-4">
|
||
<div class="w-full sticky top-0 bg-[#2c2c2e] z-10 pb-4">
|
||
<div class="flex flex-col space-y-2">
|
||
<div class="flex space-x-2">
|
||
<button
|
||
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
|
||
on:click={() => (showStyleEditor = true)}
|
||
>
|
||
<Settings class="w-4 h-4" />
|
||
<span>字幕样式</span>
|
||
</button>
|
||
<button
|
||
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
|
||
on:click={clearSubtitles}
|
||
>
|
||
<Eraser class="w-4 h-4" />
|
||
<span>清空列表</span>
|
||
</button>
|
||
</div>
|
||
<div class="flex space-x-2">
|
||
<button
|
||
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-1 border border-gray-700"
|
||
on:click={generateSubtitles}
|
||
disabled={current_generate_event_id !== null}
|
||
>
|
||
{#if current_generate_event_id !== null}
|
||
<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>
|
||
{:else}
|
||
<BrainCircuit class="w-4 h-4" />
|
||
{/if}
|
||
<span id="generate-prompt">AI 生成字幕</span>
|
||
</button>
|
||
<button
|
||
class="flex-1 px-3 py-1.5 text-sm bg-[#1c1c1e] text-gray-300 rounded-lg hover:bg-[#2c2c2e] transition-colors duration-200 flex items-center justify-center space-x-1 border border-gray-700"
|
||
on:click={addSubtitle}
|
||
>
|
||
<Plus class="w-4 h-4" />
|
||
<span>手动添加</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 字幕列表 -->
|
||
<div class="space-y-2">
|
||
{#each subtitles as subtitle, index}
|
||
<div
|
||
bind:this={subtitleElements[index]}
|
||
class="p-3 bg-[#1c1c1e] rounded-lg space-y-2 transition-colors duration-200"
|
||
class:bg-[#2c2c2e]={currentSubtitleIndex === index}
|
||
class:border={currentSubtitleIndex === index}
|
||
class:border-[#0A84FF]={currentSubtitleIndex === index}
|
||
>
|
||
<div class="flex justify-between items-center">
|
||
<div class="flex items-center space-x-4">
|
||
<div class="flex items-center space-x-1">
|
||
<button
|
||
class="text-sm text-[#0A84FF] hover:text-[#0A84FF]/80"
|
||
on:click={() => seekToTime(subtitle.startTime)}
|
||
>
|
||
{formatTime(subtitle.startTime)}
|
||
</button>
|
||
<button
|
||
class="p-0.5 text-gray-400 hover:text-white"
|
||
on:click={() => adjustTime(index, true, -0.1)}
|
||
>
|
||
<Minus class="w-3 h-3" />
|
||
</button>
|
||
<button
|
||
class="p-0.5 text-gray-400 hover:text-white"
|
||
on:click={() => adjustTime(index, true, 0.1)}
|
||
>
|
||
<Plus class="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
<span class="text-gray-400">→</span>
|
||
<div class="flex items-center space-x-1">
|
||
<button
|
||
class="text-sm text-[#0A84FF] hover:text-[#0A84FF]/80"
|
||
on:click={() => seekToTime(subtitle.endTime)}
|
||
>
|
||
{formatTime(subtitle.endTime)}
|
||
</button>
|
||
<button
|
||
class="p-0.5 text-gray-400 hover:text-white"
|
||
on:click={() => adjustTime(index, false, -0.1)}
|
||
>
|
||
<Minus class="w-3 h-3" />
|
||
</button>
|
||
<button
|
||
class="p-0.5 text-gray-400 hover:text-white"
|
||
on:click={() => adjustTime(index, false, 0.1)}
|
||
>
|
||
<Plus class="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<button
|
||
class="text-sm text-red-500 hover:text-red-400"
|
||
on:click={async () => await removeSubtitle(index)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
bind:value={subtitle.text}
|
||
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 hover:border-gray-700/50"
|
||
placeholder="输入字幕文本"
|
||
/>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{:else if activeTab === "upload"}
|
||
<!-- 投稿 Tab 内容 -->
|
||
<div class="p-4 space-y-6">
|
||
<!-- 封面预览 -->
|
||
{#if video && video.id != -1}
|
||
<section>
|
||
<div class="group">
|
||
<div
|
||
class="text-sm text-gray-400 mb-2 flex items-center justify-between"
|
||
>
|
||
<span>视频封面</span>
|
||
<button
|
||
class="text-[#0A84FF] hover:text-[#0A84FF]/80 transition-colors duration-200 flex items-center space-x-1"
|
||
on:click={() => (show_cover_editor = true)}
|
||
>
|
||
<Pen class="w-4 h-4" />
|
||
<span class="text-xs">创建新封面</span>
|
||
</button>
|
||
</div>
|
||
<div
|
||
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"
|
||
on:error={handleCoverError}
|
||
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"
|
||
>
|
||
<!-- 默认视频图标 -->
|
||
<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}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
{/if}
|
||
|
||
<!-- 基本信息 -->
|
||
<div class="space-y-4">
|
||
<h3 class="text-sm font-medium text-gray-400">基本信息</h3>
|
||
<!-- 标题 -->
|
||
<div class="space-y-2">
|
||
<label
|
||
for="title"
|
||
class="block text-sm font-medium text-gray-300">标题</label
|
||
>
|
||
<input
|
||
id="title"
|
||
type="text"
|
||
bind:value={profile.title}
|
||
placeholder="输入视频标题"
|
||
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none hover:border-gray-700/50"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 视频分区 -->
|
||
<div class="space-y-2">
|
||
<label for="tid" class="block text-sm font-medium text-gray-300"
|
||
>视频分区</label
|
||
>
|
||
<div class="w-full" id="tid">
|
||
<TypeSelect bind:value={profile.tid} />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 投稿账号 -->
|
||
<div class="space-y-2">
|
||
<label for="uid" class="block text-sm font-medium text-gray-300"
|
||
>投稿账号</label
|
||
>
|
||
<select
|
||
bind:value={uid_selected}
|
||
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none appearance-none hover:border-gray-700/50"
|
||
>
|
||
<option value={0}>选择账号</option>
|
||
{#each accounts as account}
|
||
<option value={account.value}>{account.name}</option>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细信息 -->
|
||
<div class="space-y-4">
|
||
<h3 class="text-sm font-medium text-gray-400">详细信息</h3>
|
||
<!-- 描述 -->
|
||
<div class="space-y-2">
|
||
<label
|
||
for="desc"
|
||
class="block text-sm font-medium text-gray-300">描述</label
|
||
>
|
||
<textarea
|
||
id="desc"
|
||
bind:value={profile.desc}
|
||
placeholder="输入视频描述"
|
||
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none resize-none h-24 hover:border-gray-700/50"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 标签 -->
|
||
<div class="space-y-2">
|
||
<label for="tag" class="block text-sm font-medium text-gray-300"
|
||
>标签</label
|
||
>
|
||
<input
|
||
id="tag"
|
||
type="text"
|
||
bind:value={profile.tag}
|
||
placeholder="输入视频标签,用逗号分隔"
|
||
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none hover:border-gray-700/50"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 动态 -->
|
||
<div class="space-y-2">
|
||
<label
|
||
for="dynamic"
|
||
class="block text-sm font-medium text-gray-300">动态</label
|
||
>
|
||
<textarea
|
||
id="dynamic"
|
||
bind:value={profile.dynamic}
|
||
placeholder="输入动态内容"
|
||
class="w-full px-3 py-2 bg-[#1c1c1e] text-white rounded-lg border border-gray-800/50 focus:border-[#0A84FF] transition duration-200 outline-none resize-none h-24 hover:border-gray-700/50"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 投稿按钮 -->
|
||
{#if video}
|
||
<div class="pt-4">
|
||
<div class="flex gap-2">
|
||
<button
|
||
on:click={do_post}
|
||
disabled={current_post_event_id != null || !uid_selected}
|
||
class="flex-1 px-3 py-2 bg-[#0A84FF] text-white rounded-lg transition-all duration-200 hover:bg-[#0A84FF]/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 text-sm"
|
||
>
|
||
{#if current_post_event_id != null}
|
||
<div
|
||
class="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
|
||
/>
|
||
{/if}
|
||
<span id="post-prompt">投稿</span>
|
||
</button>
|
||
{#if current_post_event_id != null}
|
||
<button
|
||
on:click={() => cancel_post()}
|
||
class="px-3 py-2 bg-red-500 text-white rounded-lg transition-all duration-200 hover:bg-red-500/90 flex items-center justify-center text-sm"
|
||
>
|
||
取消
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<SubtitleStyleEditor
|
||
bind:show={showStyleEditor}
|
||
{roomId}
|
||
onClose={() => (showStyleEditor = false)}
|
||
/>
|
||
|
||
<CoverEditor
|
||
bind:show={show_cover_editor}
|
||
{video}
|
||
on:coverUpdate={(event) => {
|
||
video = {
|
||
...video,
|
||
cover: event.detail.cover,
|
||
};
|
||
}}
|
||
/>
|
||
|
||
<!-- 键盘快捷键监听 -->
|
||
<svelte:window on:keydown={handleKeydown} />
|
||
|
||
<style>
|
||
/* 拖动时禁用过渡动画,避免与JS更新冲突 */
|
||
.no-transition {
|
||
transition: none !important;
|
||
}
|
||
|
||
/* 确保层级顺序正确 */
|
||
.z-15 {
|
||
z-index: 15;
|
||
}
|
||
|
||
/* Shaka Player风格的进度条样式 */
|
||
.shaka-seek-bar-container {
|
||
position: relative;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
transition: height 0.2s cubic-bezier(0.4, 0, 1, 1);
|
||
}
|
||
|
||
.shaka-seek-bar-container:hover {
|
||
background: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
/* 确保切片选区在hover时也有相同的高度变化 */
|
||
.group:hover .shaka-seek-bar-container {
|
||
height: 6px;
|
||
}
|
||
|
||
/* 拖动状态样式 */
|
||
.shaka-seek-bar-container.dragging {
|
||
background: rgba(255, 255, 255, 0.5);
|
||
height: 6px;
|
||
}
|
||
|
||
/* 普通range输入框样式(不影响进度条) */
|
||
input[type="range"]:not(.progress-bar) {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
|
||
input[type="range"]:not(.progress-bar)::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
input[type="range"]:not(.progress-bar)::-webkit-slider-track {
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
background: #4a5568;
|
||
}
|
||
|
||
input[type="range"]:not(.progress-bar)::-moz-range-thumb {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
input[type="range"]:not(.progress-bar)::-moz-range-track {
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
background: #4a5568;
|
||
}
|
||
</style>
|