This commit is contained in:
Julian Freeman
2026-04-19 09:56:09 -04:00
parent 4d5cac7a46
commit e86bc86793
18 changed files with 685 additions and 500 deletions

View File

@@ -1,6 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion

View File

@@ -10,6 +10,7 @@ use std::os::windows::process::CommandExt;
use zip::ZipArchive;
use std::io::Cursor;
use crate::process_utils::first_non_empty_line;
use crate::storage::{self};
const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
@@ -285,6 +286,11 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
pub async fn update_qjs(app: &AppHandle) -> Result<String> {
// QuickJS doesn't have self-update, so we just re-download
download_qjs(app).await?;
let mut settings = storage::load_settings(app)?;
settings.last_updated = Some(chrono::Utc::now());
storage::save_settings(app, &settings)?;
Ok("QuickJS 已更新/安装".to_string())
}
@@ -441,18 +447,8 @@ pub fn get_ffmpeg_version(app: &AppHandle) -> Result<String> {
let output = cmd.output()?;
if output.status.success() {
// Prefer stdout, fallback to stderr if stdout empty
let out = if !output.stdout.is_empty() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
String::from_utf8_lossy(&output.stderr).to_string()
};
if let Some(first_line) = out.lines().next() {
let v = first_line.trim().to_string();
if !v.is_empty() {
return Ok(v);
}
if let Some(line) = first_non_empty_line(&output) {
return Ok(line);
}
}
@@ -465,6 +461,31 @@ pub fn get_qjs_version(app: &AppHandle) -> Result<String> {
if !path.exists() {
return Ok("未安装".to_string());
}
let mut version_cmd = std::process::Command::new(&path);
version_cmd.arg("--version");
#[cfg(target_os = "windows")]
version_cmd.creation_flags(0x08000000);
if let Ok(output) = version_cmd.output() {
if output.status.success() {
if let Some(line) = first_non_empty_line(&output) {
return Ok(line);
}
}
}
let mut help_cmd = std::process::Command::new(&path);
help_cmd.arg("-h");
#[cfg(target_os = "windows")]
help_cmd.creation_flags(0x08000000);
if let Ok(output) = help_cmd.output() {
if let Some(line) = first_non_empty_line(&output) {
return Ok(line);
}
}
Ok("已安装".to_string())
}

View File

@@ -5,6 +5,10 @@ use crate::downloader::DownloadOptions;
use crate::storage::{Settings, HistoryItem};
use uuid::Uuid;
use std::path::Path;
use std::sync::LazyLock;
use tokio::sync::Semaphore;
static DOWNLOAD_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(3));
#[tauri::command]
pub async fn init_ytdlp(app: AppHandle) -> Result<bool, String> {
@@ -60,14 +64,29 @@ pub async fn fetch_image(url: String) -> Result<String, String> {
.await
.map_err(|e| e.to_string())?;
if !res.status().is_success() {
return Err(format!("image fetch failed with status {}", res.status()));
}
let mime = res
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or("image/jpeg").to_string())
.unwrap_or_else(|| {
if url.to_lowercase().ends_with(".png") {
"image/png".to_string()
} else if url.to_lowercase().ends_with(".webp") {
"image/webp".to_string()
} else {
"image/jpeg".to_string()
}
});
let bytes = res.bytes().await.map_err(|e| e.to_string())?;
// Convert to base64
let b64 = general_purpose::STANDARD.encode(&bytes);
// Simple heuristic for mime type
let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else { "image/jpeg" };
Ok(format!("data:{};base64,{}", mime, b64))
}
@@ -84,9 +103,11 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
// Spawn the download task
tauri::async_runtime::spawn(async move {
let _permit = DOWNLOAD_SEMAPHORE.acquire().await.ok();
let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await;
let status = if res.is_ok() { "success" } else { "failed" };
let file_path = res.ok().flatten();
// Add to history
let output_dir = options.output_path.clone(); // Store the directory user selected
@@ -97,6 +118,7 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
thumbnail: metadata.thumbnail,
url: url,
output_path: output_dir,
file_path,
timestamp: chrono::Utc::now(),
status: status.to_string(),
format: options.output_format,
@@ -136,16 +158,16 @@ pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> {
#[tauri::command]
pub async fn close_splash(app: AppHandle) {
if let Some(splash) = app.get_webview_window("splashscreen") {
splash.close().unwrap();
let _ = splash.close();
}
if let Some(main) = app.get_webview_window("main") {
main.show().unwrap();
let _ = main.show();
}
}
#[tauri::command]
pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
let path_to_open = if Path::new(&path).exists() {
let resolved_path = if Path::new(&path).exists() {
path
} else {
app.path().download_dir()
@@ -155,17 +177,29 @@ pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(path_to_open)
.spawn()
.map_err(|e| e.to_string())?;
let resolved = Path::new(&resolved_path);
let mut command = std::process::Command::new("explorer");
if resolved.is_file() {
command.arg("/select,").arg(resolved);
} else {
command.arg(resolved);
}
command.spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(path_to_open)
.spawn()
.map_err(|e| e.to_string())?;
let resolved = Path::new(&resolved_path);
let mut command = std::process::Command::new("open");
if resolved.is_file() {
command.arg("-R").arg(resolved);
} else {
command.arg(resolved);
}
command.spawn().map_err(|e| e.to_string())?;
}
Ok(())
}
}

View File

@@ -57,6 +57,7 @@ pub struct LogEvent {
pub level: String, // "info", "error"
}
const FINAL_PATH_MARKER: &str = "__STREAM_CAPTURE_FINAL_PATH__";
pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result<MetadataResult> {
@@ -218,7 +219,7 @@ pub async fn download_video(
id: String, // Unique ID for this download task (provided by frontend)
url: String,
options: DownloadOptions,
) -> Result<String> {
) -> Result<Option<String>> {
let ytdlp_path = binary_manager::get_ytdlp_path(&app)?;
let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs
let ffmpeg_path = binary_manager::get_ffmpeg_path(&app)?; // Get absolute path to ffmpeg
@@ -246,6 +247,8 @@ pub async fn download_video(
let output_template = format!("{}/%(title)s.%(ext)s", options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR));
args.push("-o".to_string());
args.push(output_template);
args.push("--print".to_string());
args.push(format!("after_move:{FINAL_PATH_MARKER}%(filepath)s"));
// Formats
if options.is_audio_only {
@@ -295,56 +298,84 @@ pub async fn download_video(
let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?;
let stderr = child.stderr.take().ok_or(anyhow!("Failed to open stderr"))?;
let mut stdout_reader = BufReader::new(stdout);
let mut stderr_reader = BufReader::new(stderr);
let progress_regex = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%.*?(?:\s+at\s+([^\s]+))?").unwrap();
let re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap();
let stdout_task = {
let app = app.clone();
let id = id.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
let mut final_path: Option<String> = None;
// Loop to read both streams
loop {
let mut out_line = String::new();
let mut err_line = String::new();
tokio::select! {
res = stdout_reader.read_line(&mut out_line) => {
if res.unwrap_or(0) == 0 {
break; // EOF
while let Some(line) = reader.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Parse progress
if let Some(caps) = re.captures(&out_line) {
if let Some(path) = trimmed.strip_prefix(FINAL_PATH_MARKER) {
final_path = Some(path.to_string());
continue;
}
if let Some(caps) = progress_regex.captures(trimmed) {
if let Some(pct_match) = caps.get(1) {
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
let speed = caps
.get(2)
.map(|value| value.as_str().to_string())
.unwrap_or_else(|| "待定".to_string());
app.emit("download-progress", ProgressEvent {
id: id.clone(),
progress: pct,
speed: "待定".to_string(),
speed,
status: "downloading".to_string(),
}).ok();
continue;
}
}
} else { // Only emit download-log if it's NOT a progress line
app.emit("download-log", LogEvent {
id: id.clone(),
message: out_line.trim().to_string(),
level: "info".to_string(),
}).ok();
}
app.emit("download-log", LogEvent {
id: id.clone(),
message: trimmed.to_string(),
level: "info".to_string(),
}).ok();
}
res = stderr_reader.read_line(&mut err_line) => {
if res.unwrap_or(0) > 0 {
// Log error
app.emit("download-log", LogEvent {
id: id.clone(),
message: err_line.trim().to_string(),
level: "error".to_string(),
}).ok();
}
Ok::<Option<String>, anyhow::Error>(final_path)
})
};
let stderr_task = {
let app = app.clone();
let id = id.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
let mut last_error: Option<String> = None;
while let Some(line) = reader.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
last_error = Some(trimmed.to_string());
app.emit("download-log", LogEvent {
id: id.clone(),
message: trimmed.to_string(),
level: "error".to_string(),
}).ok();
}
}
}
Ok::<Option<String>, anyhow::Error>(last_error)
})
};
let status = child.wait().await?;
let final_path = stdout_task.await.map_err(|e| anyhow!(e.to_string()))??;
let last_error = stderr_task.await.map_err(|e| anyhow!(e.to_string()))??;
if status.success() {
app.emit("download-progress", ProgressEvent {
@@ -353,7 +384,7 @@ pub async fn download_video(
speed: "-".to_string(),
status: "finished".to_string(),
}).ok();
Ok("下载完成".to_string())
Ok(final_path)
} else {
app.emit("download-progress", ProgressEvent {
id: id.clone(),
@@ -361,6 +392,11 @@ pub async fn download_video(
speed: "-".to_string(),
status: "error".to_string(),
}).ok();
Err(anyhow!("下载进程失败"))
Err(anyhow!(
"下载进程失败{}",
last_error
.map(|message| format!(": {message}"))
.unwrap_or_default()
))
}
}

View File

@@ -3,6 +3,7 @@ mod binary_manager;
mod downloader;
mod storage;
mod commands;
mod process_utils;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {

View File

@@ -0,0 +1,10 @@
use std::process::Output;
pub fn first_non_empty_line(output: &Output) -> Option<String> {
String::from_utf8_lossy(&output.stdout)
.lines()
.chain(String::from_utf8_lossy(&output.stderr).lines())
.map(str::trim)
.find(|line| !line.is_empty())
.map(str::to_string)
}

View File

@@ -34,11 +34,35 @@ pub struct HistoryItem {
pub thumbnail: String,
pub url: String,
pub output_path: String,
#[serde(default)]
pub file_path: Option<String>,
pub timestamp: DateTime<Utc>,
pub status: String, // "success", "failed"
pub format: String,
}
fn write_json_atomically(path: &PathBuf, content: &str) -> Result<()> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("data.json");
let tmp_path = path.with_file_name(format!("{file_name}.tmp"));
fs::write(&tmp_path, content)?;
if path.exists() {
match fs::rename(&tmp_path, path) {
Ok(()) => return Ok(()),
Err(_) => {
fs::remove_file(path)?;
}
}
}
fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn get_app_data_dir(app: &AppHandle) -> Result<PathBuf> {
// In Tauri v2, we use app.path().app_data_dir()
let path = app.path().app_data_dir()?;
@@ -76,7 +100,7 @@ pub fn load_settings(app: &AppHandle) -> Result<Settings> {
pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> {
let path = get_settings_path(app)?;
let content = serde_json::to_string_pretty(settings)?;
fs::write(path, content)?;
write_json_atomically(&path, &content)?;
Ok(())
}
@@ -94,7 +118,7 @@ pub fn load_history(app: &AppHandle) -> Result<Vec<HistoryItem>> {
pub fn save_history(app: &AppHandle, history: &[HistoryItem]) -> Result<()> {
let path = get_history_path(app)?;
let content = serde_json::to_string_pretty(history)?;
fs::write(path, content)?;
write_json_atomically(&path, &content)?;
Ok(())
}

View File

@@ -31,7 +31,7 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self' asset: http://asset.localhost https://asset.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost ws://localhost:1420 http://localhost:1420 https:; font-src 'self' asset: http://asset.localhost https://asset.localhost data:;"
}
},
"bundle": {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onBeforeUnmount, onMounted } from 'vue'
import { RouterView, RouterLink, useRoute } from 'vue-router'
import { Home, History, Settings as SettingsIcon, Download, FileText } from 'lucide-vue-next'
import { useSettingsStore } from './stores/settings'
@@ -13,10 +13,17 @@ const route = useRoute()
onMounted(async () => {
await settingsStore.loadSettings()
settingsStore.initThemeListener()
await settingsStore.initYtdlp()
await queueStore.initListener()
await logsStore.initListener()
})
onBeforeUnmount(() => {
settingsStore.disposeThemeListener()
queueStore.disposeListener()
logsStore.disposeListener()
})
</script>
<template>
@@ -74,4 +81,4 @@ onMounted(async () => {
<RouterView />
</main>
</div>
</template>
</template>

View File

@@ -1,12 +1,14 @@
// filepath: src/stores/analysis.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { AnalysisMetadata } from '../types/media'
import { isPlaylistMetadata } from '../types/media'
export const useAnalysisStore = defineStore('analysis', () => {
const url = ref('')
const loading = ref(false)
const error = ref('')
const metadata = ref<any>(null)
const metadata = ref<AnalysisMetadata | null>(null)
// New state for mix detection
const isMix = ref(false)
@@ -24,8 +26,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
})
function toggleEntry(id: string) {
if (metadata.value && metadata.value.entries) {
const entry = metadata.value.entries.find((e: any) => e.id === id)
if (isPlaylistMetadata(metadata.value)) {
const entry = metadata.value.entries.find(e => e.id === id)
if (entry) {
entry.selected = !entry.selected
}
@@ -33,8 +35,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
}
function setAllEntries(selected: boolean) {
if (metadata.value && metadata.value.entries) {
metadata.value.entries = metadata.value.entries.map((e: any) => ({
if (isPlaylistMetadata(metadata.value)) {
metadata.value.entries = metadata.value.entries.map(e => ({
...e,
selected
}))
@@ -42,8 +44,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
}
function invertSelection() {
if (metadata.value && metadata.value.entries) {
metadata.value.entries = metadata.value.entries.map((e: any) => ({
if (isPlaylistMetadata(metadata.value)) {
metadata.value.entries = metadata.value.entries.map(e => ({
...e,
selected: !e.selected
}))
@@ -60,4 +62,4 @@ export const useAnalysisStore = defineStore('analysis', () => {
}
return { url, loading, error, metadata, options, isMix, scanMix, isBatchMode, toggleEntry, setAllEntries, invertSelection, reset }
})
})

View File

@@ -20,6 +20,7 @@ interface LogEvent {
export const useLogsStore = defineStore('logs', () => {
const logs = ref<LogEntry[]>([])
const isListening = ref(false)
let unlisten: (() => void) | null = null
function addLog(taskId: string, message: string, level: 'info' | 'error') {
logs.value.push({
@@ -40,7 +41,7 @@ export const useLogsStore = defineStore('logs', () => {
if (isListening.value) return
isListening.value = true
await listen<LogEvent>('download-log', (event) => {
unlisten = await listen<LogEvent>('download-log', (event) => {
const { id, message, level } = event.payload
addLog(id, message, level as 'info' | 'error')
})
@@ -54,5 +55,11 @@ export const useLogsStore = defineStore('logs', () => {
const autoScroll = ref(true)
const scrollTop = ref(0)
return { logs, addLog, initListener, clearLogs, autoScroll, scrollTop }
function disposeListener() {
unlisten?.()
unlisten = null
isListening.value = false
}
return { logs, addLog, initListener, clearLogs, disposeListener, autoScroll, scrollTop }
})

View File

@@ -22,6 +22,7 @@ interface ProgressEvent {
export const useQueueStore = defineStore('queue', () => {
const tasks = ref<DownloadTask[]>([])
const isListening = ref(false)
let unlisten: (() => void) | null = null
function addTask(task: DownloadTask) {
tasks.value.push(task)
@@ -31,7 +32,7 @@ export const useQueueStore = defineStore('queue', () => {
if (isListening.value) return
isListening.value = true
await listen<ProgressEvent>('download-progress', (event) => {
unlisten = await listen<ProgressEvent>('download-progress', (event) => {
const { id, progress, speed, status } = event.payload
const task = tasks.value.find(t => t.id === id)
if (task) {
@@ -43,5 +44,11 @@ export const useQueueStore = defineStore('queue', () => {
})
}
return { tasks, addTask, initListener }
function disposeListener() {
unlisten?.()
unlisten = null
isListening.value = false
}
return { tasks, addTask, initListener, disposeListener }
})

View File

@@ -22,6 +22,9 @@ export const useSettingsStore = defineStore('settings', () => {
const quickjsVersion = ref('Checking...')
const ffmpegVersion = ref('Checking...')
const isInitializing = ref(true)
const hasInitialized = ref(false)
let mediaQuery: MediaQueryList | null = null
let mediaListener: (() => void) | null = null
async function loadSettings() {
try {
@@ -43,11 +46,13 @@ export const useSettingsStore = defineStore('settings', () => {
}
async function initYtdlp() {
if (hasInitialized.value) return
try {
isInitializing.value = true
// check/download
await invoke('init_ytdlp')
await refreshVersions()
hasInitialized.value = true
} catch (e) {
console.error(e)
ytdlpVersion.value = 'Error'
@@ -59,9 +64,15 @@ export const useSettingsStore = defineStore('settings', () => {
}
async function refreshVersions() {
ytdlpVersion.value = await invoke('get_ytdlp_version')
quickjsVersion.value = await invoke('get_quickjs_version')
ffmpegVersion.value = await invoke('get_ffmpeg_version')
const [ytdlp, quickjs, ffmpeg] = await Promise.allSettled([
invoke<string>('get_ytdlp_version'),
invoke<string>('get_quickjs_version'),
invoke<string>('get_ffmpeg_version')
])
ytdlpVersion.value = ytdlp.status === 'fulfilled' ? ytdlp.value : 'Error'
quickjsVersion.value = quickjs.status === 'fulfilled' ? quickjs.value : 'Error'
ffmpegVersion.value = ffmpeg.status === 'fulfilled' ? ffmpeg.value : 'Error'
}
function applyTheme(theme: string) {
@@ -74,12 +85,36 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// Watch system preference changes if theme is system
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
function initThemeListener() {
if (mediaQuery) return
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaListener = () => {
if (settings.value.theme === 'system') {
applyTheme('system')
applyTheme('system')
}
})
}
mediaQuery.addEventListener('change', mediaListener)
}
return { settings, loadSettings, save, initYtdlp, refreshVersions, ytdlpVersion, quickjsVersion, ffmpegVersion, isInitializing }
})
function disposeThemeListener() {
if (mediaQuery && mediaListener) {
mediaQuery.removeEventListener('change', mediaListener)
}
mediaQuery = null
mediaListener = null
}
return {
settings,
loadSettings,
save,
initYtdlp,
refreshVersions,
initThemeListener,
disposeThemeListener,
ytdlpVersion,
quickjsVersion,
ffmpegVersion,
isInitializing
}
})

24
src/types/media.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface VideoMetadata {
id: string
title: string
thumbnail: string
duration?: number | null
uploader?: string | null
url?: string | null
}
export interface SelectableVideoMetadata extends VideoMetadata {
selected: boolean
}
export interface PlaylistMetadata {
id: string
title: string
entries: SelectableVideoMetadata[]
}
export type AnalysisMetadata = VideoMetadata | PlaylistMetadata
export function isPlaylistMetadata(metadata: AnalysisMetadata | null): metadata is PlaylistMetadata {
return !!metadata && Array.isArray((metadata as PlaylistMetadata).entries)
}

45
src/utils/analysis.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { PlaylistMetadata, SelectableVideoMetadata, VideoMetadata } from '../types/media'
export function detectMixUrl(url: string): boolean {
return url.includes('v=') && url.includes('list=')
}
export function stripPlaylistContext(rawUrl: string): string {
try {
const url = new URL(rawUrl)
url.searchParams.delete('list')
url.searchParams.delete('index')
url.searchParams.delete('start_radio')
return url.toString()
} catch {
return rawUrl
.replace(/[?&]list=[^&]+/g, '')
.replace(/[?&]index=[^&]+/g, '')
.replace(/[?&]start_radio=[^&]+/g, '')
.replace('?&', '?')
.replace(/&&+/g, '&')
.replace(/[?&]$/, '')
}
}
export function isLikelyHttpUrl(input: string): boolean {
return input.startsWith('http://') || input.startsWith('https://')
}
export function normalizeBatchLinks(input: string): string[] {
return input
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.filter(isLikelyHttpUrl)
.filter(link => !link.includes('list=') || detectMixUrl(link))
.map(link => detectMixUrl(link) ? stripPlaylistContext(link) : link)
}
export function toSelectableEntries(entries: VideoMetadata[]): SelectableVideoMetadata[] {
return entries.map(entry => ({ ...entry, selected: true }))
}
export function countSelectedEntries(metadata: PlaylistMetadata | null): number {
return metadata?.entries.filter(entry => entry.selected).length ?? 0
}

View File

@@ -11,6 +11,7 @@ interface HistoryItem {
thumbnail: string
url: string
output_path: string
file_path?: string | null
timestamp: string
status: string
format: string
@@ -114,7 +115,7 @@ onMounted(loadHistory)
</td>
<td class="px-6 py-4 text-right whitespace-nowrap">
<button
@click="openFolder(item.output_path)"
@click="openFolder(item.file_path || item.output_path)"
class="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
title="打开输出文件夹"
>
@@ -134,4 +135,4 @@ onMounted(loadHistory)
</div>
</div>
</div>
</template>
</template>

View File

@@ -1,11 +1,20 @@
<script setup lang="ts">
import { watch } from 'vue'
import { computed, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { Loader2, List, Link } from 'lucide-vue-next'
import { useQueueStore } from '../stores/queue'
import { useSettingsStore } from '../stores/settings'
import { useAnalysisStore } from '../stores/analysis'
import AppSelect from '../components/ui/AppSelect.vue'
import type { AnalysisMetadata, PlaylistMetadata, VideoMetadata } from '../types/media'
import { isPlaylistMetadata } from '../types/media'
import {
countSelectedEntries,
detectMixUrl,
normalizeBatchLinks,
stripPlaylistContext,
toSelectableEntries
} from '../utils/analysis'
const queueStore = useQueueStore()
const settingsStore = useSettingsStore()
@@ -38,169 +47,135 @@ const audioFormatOptions = [
{ label: 'FLAC', value: 'flac' },
]
// Sync default download path if not set
const playlistMetadata = computed<PlaylistMetadata | null>(() => {
return isPlaylistMetadata(analysisStore.metadata) ? analysisStore.metadata : null
})
const singleMetadata = computed<VideoMetadata | null>(() => {
return analysisStore.metadata && !isPlaylistMetadata(analysisStore.metadata)
? analysisStore.metadata
: null
})
const selectedCount = computed(() => countSelectedEntries(playlistMetadata.value))
watch(() => settingsStore.settings.download_path, (newPath) => {
if (newPath && !analysisStore.options.output_path) {
analysisStore.options.output_path = newPath
}
if (newPath && !analysisStore.options.output_path) {
analysisStore.options.output_path = newPath
}
}, { immediate: true })
// Detect Mix URL
watch(() => analysisStore.url, (newUrl) => {
if (newUrl && newUrl.includes('v=') && newUrl.includes('list=')) {
analysisStore.isMix = true
} else {
analysisStore.isMix = false
// Reset scanMix if URL changes to non-mix
analysisStore.scanMix = false
}
analysisStore.isMix = !!newUrl && detectMixUrl(newUrl)
if (!analysisStore.isMix) {
analysisStore.scanMix = false
}
})
// Reset format to original when toggling audio-only mode
watch(() => analysisStore.options.is_audio_only, () => {
analysisStore.options.output_format = 'original'
analysisStore.options.output_format = 'original'
})
async function processThumbnail(url: string | undefined): Promise<string | undefined> {
if (!url) return undefined;
// Check if it's an Instagram URL or similar that needs proxying
if (url.includes('instagram.com') || url.includes('fbcdn.net') || url.includes('cdninstagram.com')) {
try {
return await invoke<string>('fetch_image', { url });
} catch (e) {
console.warn('Thumbnail fetch failed, falling back to URL', e);
return url;
}
async function processThumbnail(url: string | undefined | null): Promise<string | undefined> {
if (!url) return undefined
if (url.includes('instagram.com') || url.includes('fbcdn.net') || url.includes('cdninstagram.com')) {
try {
return await invoke<string>('fetch_image', { url })
} catch (error) {
console.warn('Thumbnail fetch failed, falling back to URL', error)
return url
}
return url;
}
return url
}
async function processMetadataThumbnails(metadata: any) {
if (!metadata) return;
// Process single video thumbnail
if (metadata.thumbnail) {
metadata.thumbnail = await processThumbnail(metadata.thumbnail);
async function processMetadataThumbnails(metadata: AnalysisMetadata) {
if (isPlaylistMetadata(metadata)) {
await Promise.all(metadata.entries.map(async (entry) => {
if (entry.thumbnail) {
entry.thumbnail = await processThumbnail(entry.thumbnail) ?? entry.thumbnail
}
}))
return
}
if (metadata.thumbnail) {
metadata.thumbnail = await processThumbnail(metadata.thumbnail) ?? metadata.thumbnail
}
}
async function analyzeBatchLinks() {
const validLinks = normalizeBatchLinks(analysisStore.url)
if (validLinks.length === 0) {
throw new Error('未找到有效的单个视频链接(已忽略纯播放列表链接)。')
}
const results: VideoMetadata[] = []
for (const link of validLinks) {
try {
const res = await invoke<AnalysisMetadata>('fetch_metadata', { url: link, parseMixPlaylist: false })
if (!isPlaylistMetadata(res)) {
results.push(res)
}
} catch (error) {
console.warn(`Failed to parse ${link}`, error)
}
// Process playlist entries
if (metadata.entries && Array.isArray(metadata.entries)) {
await Promise.all(metadata.entries.map(async (entry: any) => {
if (entry.thumbnail) {
entry.thumbnail = await processThumbnail(entry.thumbnail);
}
}));
}
if (results.length === 0) {
throw new Error('所有链接解析失败或均为播放列表。')
}
await Promise.all(results.map(result => processMetadataThumbnails(result)))
analysisStore.metadata = {
id: `batch_download_${Date.now()}`,
title: `批量解析结果 (${results.length} 个视频)`,
entries: toSelectableEntries(results)
}
}
async function analyzeSingleLink() {
let urlToScan = analysisStore.url
let parseMix = false
if (analysisStore.isMix) {
if (analysisStore.scanMix) {
parseMix = true
} else {
urlToScan = stripPlaylistContext(urlToScan)
}
}
const res = await invoke<AnalysisMetadata>('fetch_metadata', { url: urlToScan, parseMixPlaylist: parseMix })
await processMetadataThumbnails(res)
if (isPlaylistMetadata(res)) {
res.entries = toSelectableEntries(res.entries)
}
analysisStore.metadata = res
}
async function analyze() {
if (!analysisStore.url) return
analysisStore.loading = true
analysisStore.error = ''
analysisStore.metadata = null
try {
if (analysisStore.isBatchMode) {
// Batch Mode Logic
const lines = analysisStore.url.split('\n').map(l => l.trim()).filter(l => l);
const validLinks = lines.filter(l => {
if (!(l.startsWith('http://') || l.startsWith('https://'))) return false;
// Allow if it has NO list param
if (!l.includes('list=')) return true;
// Allow if it has list param BUT ALSO has v= (video in playlist/mix)
if (l.includes('list=') && l.includes('v=')) return true;
// Otherwise ignore (pure playlist)
return false;
});
if (validLinks.length === 0) {
throw new Error("未找到有效的单个视频链接(已忽略纯播放列表链接)。");
}
const results: any[] = [];
// Process sequentially to be safe
for (let link of validLinks) {
try {
// If it's a mix/playlist context (has v= and list=), strip the list param to treat as single video
if (link.includes('list=') && link.includes('v=')) {
try {
const u = new URL(link);
u.searchParams.delete('list');
u.searchParams.delete('index');
u.searchParams.delete('start_radio');
link = u.toString();
} catch (e) {
link = link.replace(/&list=[^&]+/, '');
}
}
// Ensure we don't accidentally trigger mix parsing on single links if logic fails
const res = await invoke<any>('fetch_metadata', { url: link, parseMixPlaylist: false });
// Only add if it's a single video (no entries)
if (!res.entries) {
results.push(res);
}
} catch (e) {
console.warn(`Failed to parse ${link}`, e);
}
}
if (results.length === 0) {
throw new Error("所有链接解析失败或均为播放列表。");
}
// Process thumbnails for batch results
await Promise.all(results.map(r => processMetadataThumbnails(r)));
// Construct synthetic playlist
analysisStore.metadata = {
id: 'batch_download_' + Date.now(),
title: `批量解析结果 (${results.length} 个视频)`,
entries: results.map(e => ({ ...e, selected: true }))
};
await analyzeBatchLinks()
} else {
// Single Link Mode
let urlToScan = analysisStore.url;
let parseMix = false;
if (analysisStore.isMix) {
if (analysisStore.scanMix) {
// Keep URL as is, tell backend to limit scan
parseMix = true;
} else {
// Strip list param
try {
const u = new URL(urlToScan);
u.searchParams.delete('list');
u.searchParams.delete('index');
u.searchParams.delete('start_radio');
urlToScan = u.toString();
} catch (e) {
// Fallback regex if URL parsing fails
urlToScan = urlToScan.replace(/&list=[^&]+/, '');
}
}
}
const res = await invoke<any>('fetch_metadata', { url: urlToScan, parseMixPlaylist: parseMix })
await processMetadataThumbnails(res);
// Initialize selected state for playlist entries
if (res.entries) {
res.entries = res.entries.map((e: any) => ({ ...e, selected: true }))
}
analysisStore.metadata = res
await analyzeSingleLink()
}
} catch (e: any) {
analysisStore.error = e.toString()
} catch (error: unknown) {
analysisStore.error = error instanceof Error ? error.message : String(error)
} finally {
analysisStore.loading = false
}
@@ -208,78 +183,63 @@ async function analyze() {
async function startDownload() {
if (!analysisStore.metadata) return
// Ensure path is set
if (!analysisStore.options.output_path) {
analysisStore.options.output_path = settingsStore.settings.download_path
analysisStore.options.output_path = settingsStore.settings.download_path
}
// Ensure cookies path is set from settings
analysisStore.options.cookies_path = settingsStore.settings.cookies_path || ''
try {
if (analysisStore.metadata.entries) {
// Playlist Download
const selectedEntries = analysisStore.metadata.entries.filter((e: any) => e.selected)
if (selectedEntries.length === 0) {
analysisStore.error = "请至少选择一个要下载的视频。"
return
}
if (playlistMetadata.value) {
const selectedEntries = playlistMetadata.value.entries.filter(entry => entry.selected)
for (const entry of selectedEntries) {
const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`
const id = await invoke<string>('start_download', {
url: videoUrl,
options: analysisStore.options,
metadata: entry // Pass the individual video metadata
})
if (selectedEntries.length === 0) {
analysisStore.error = '请至少选择一个要下载的视频。'
return
}
queueStore.addTask({
id,
title: entry.title,
thumbnail: entry.thumbnail,
progress: 0,
speed: '等待中...',
status: 'pending'
})
}
} else {
// Single Video Download
let urlToDownload = analysisStore.url;
// Clean URL if it was a mix but we didn't scan it as one
if (analysisStore.isMix && !analysisStore.scanMix) {
try {
const u = new URL(urlToDownload);
u.searchParams.delete('list');
u.searchParams.delete('index');
u.searchParams.delete('start_radio');
urlToDownload = u.toString();
} catch (e) {
urlToDownload = urlToDownload.replace(/&list=[^&]+/, '');
}
}
const id = await invoke<string>('start_download', {
url: urlToDownload,
options: analysisStore.options,
metadata: analysisStore.metadata
for (const entry of selectedEntries) {
const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`
const id = await invoke<string>('start_download', {
url: videoUrl,
options: analysisStore.options,
metadata: entry
})
queueStore.addTask({
id,
title: analysisStore.metadata.title,
thumbnail: analysisStore.metadata.thumbnail,
progress: 0,
speed: '等待中...',
status: 'pending'
})
queueStore.addTask({
id,
title: entry.title,
thumbnail: entry.thumbnail,
progress: 0,
speed: '等待中...',
status: 'pending'
})
}
// Reset state after successful download start
analysisStore.reset()
} catch (e: any) {
analysisStore.error = "下载启动失败: " + e.toString()
} else if (singleMetadata.value) {
const urlToDownload = analysisStore.isMix && !analysisStore.scanMix
? stripPlaylistContext(analysisStore.url)
: analysisStore.url
const id = await invoke<string>('start_download', {
url: urlToDownload,
options: analysisStore.options,
metadata: singleMetadata.value
})
queueStore.addTask({
id,
title: singleMetadata.value.title,
thumbnail: singleMetadata.value.thumbnail,
progress: 0,
speed: '等待中...',
status: 'pending'
})
}
analysisStore.reset()
} catch (error: unknown) {
analysisStore.error = `下载启动失败: ${error instanceof Error ? error.message : String(error)}`
}
}
</script>
@@ -288,253 +248,223 @@ async function startDownload() {
<div class="max-w-5xl mx-auto p-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">新建下载</h1>
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">粘贴 URL 开始下载媒体</p> -->
</header>
<!-- Input Section -->
<div class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 mb-8">
<!-- Input Area with Batch Toggle -->
<div class="flex flex-col gap-3">
<div class="flex justify-end mb-1">
<button
@click="analysisStore.isBatchMode = !analysisStore.isBatchMode"
class="text-xs font-medium flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors"
:class="analysisStore.isBatchMode ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-600 dark:bg-zinc-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-zinc-700'"
>
<List v-if="analysisStore.isBatchMode" class="w-3.5 h-3.5" />
<Link v-else class="w-3.5 h-3.5" />
{{ analysisStore.isBatchMode ? '单链接模式' : '批量输入模式' }}
</button>
<button
@click="analysisStore.isBatchMode = !analysisStore.isBatchMode"
class="text-xs font-medium flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors"
:class="analysisStore.isBatchMode ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-600 dark:bg-zinc-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-zinc-700'"
>
<List v-if="analysisStore.isBatchMode" class="w-3.5 h-3.5" />
<Link v-else class="w-3.5 h-3.5" />
{{ analysisStore.isBatchMode ? '单链接模式' : '批量输入模式' }}
</button>
</div>
<div class="flex gap-4 items-start">
<div class="flex-1 relative">
<textarea
v-if="analysisStore.isBatchMode"
v-model="analysisStore.url"
placeholder="每行输入一个链接..."
rows="5"
class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none resize-none font-mono text-sm leading-relaxed"
></textarea>
<input
v-else
v-model="analysisStore.url"
type="text"
placeholder="https://..."
class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
@keyup.enter="analyze"
/>
</div>
<button
@click="analyze"
:disabled="analysisStore.loading"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-medium transition-colors disabled:opacity-50 flex items-center gap-2 shrink-0 h-[48px]"
>
<Loader2 v-if="analysisStore.loading" class="animate-spin w-5 h-5" />
<span v-else>解析</span>
</button>
<div class="flex-1 relative">
<textarea
v-if="analysisStore.isBatchMode"
v-model="analysisStore.url"
placeholder="每行输入一个链接..."
rows="5"
class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none resize-none font-mono text-sm leading-relaxed"
></textarea>
<input
v-else
v-model="analysisStore.url"
type="text"
placeholder="https://..."
class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
@keyup.enter="analyze"
/>
</div>
<button
@click="analyze"
:disabled="analysisStore.loading"
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-medium transition-colors disabled:opacity-50 flex items-center gap-2 shrink-0 h-[48px]"
>
<Loader2 v-if="analysisStore.loading" class="animate-spin w-5 h-5" />
<span v-else>解析</span>
</button>
</div>
</div>
<!-- Mix Toggle (Only in Single Mode) -->
<div v-if="!analysisStore.isBatchMode && analysisStore.isMix" class="mt-4 flex items-center gap-3">
<button
@click="analysisStore.scanMix = !analysisStore.scanMix"
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out flex-shrink-0"
:class="analysisStore.scanMix ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
:class="analysisStore.scanMix ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
<span class="text-sm font-medium text-zinc-700 dark:text-gray-300">解析播放列表 (前20项)</span>
<button
@click="analysisStore.scanMix = !analysisStore.scanMix"
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out flex-shrink-0"
:class="analysisStore.scanMix ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
:class="analysisStore.scanMix ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
<span class="text-sm font-medium text-zinc-700 dark:text-gray-300">解析播放列表 (前20项)</span>
</div>
<p v-if="analysisStore.error" class="mt-3 text-red-500 text-sm">{{ analysisStore.error }}</p>
</div>
<!-- Analysis Result -->
<div v-if="analysisStore.metadata" class="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden mb-8">
<!-- Playlist Header / Global Controls -->
<div v-if="analysisStore.metadata.entries" class="p-6 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-xl font-bold text-zinc-900 dark:text-white">{{ analysisStore.metadata.title }}</h2>
<p class="text-blue-500 mt-1 font-medium">{{ analysisStore.metadata.entries.length }} 个视频</p>
</div>
<div v-if="playlistMetadata" class="p-6 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-start justify-between mb-4">
<div>
<h2 class="text-xl font-bold text-zinc-900 dark:text-white">{{ playlistMetadata.title }}</h2>
<p class="text-blue-500 mt-1 font-medium">{{ playlistMetadata.entries.length }} 个视频</p>
</div>
<!-- Global Options Bar -->
<div class="flex flex-col md:flex-row items-center justify-between gap-4 bg-gray-50 dark:bg-zinc-800/50 p-4 rounded-xl">
<!-- Left: Selection Controls -->
<div class="flex items-center gap-2 w-full md:w-auto">
<button @click="analysisStore.setAllEntries(true)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">全选</button>
<button @click="analysisStore.setAllEntries(false)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">取消全选</button>
<button @click="analysisStore.invertSelection()" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">反选</button>
</div>
</div>
<!-- Right: Settings -->
<div class="flex items-center gap-6 w-full md:w-auto justify-end">
<!-- Audio Only Toggle -->
<div class="flex items-center gap-x-4 p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
<span class="font-medium text-base text-zinc-700 dark:text-gray-300">仅音频</span>
<button
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
</div>
<!-- Quality Dropdown -->
<div class="flex items-center gap-3">
<div class="w-44">
<AppSelect
v-model="analysisStore.options.quality"
:options="qualityOptions"
:disabled="analysisStore.options.is_audio_only"
/>
</div>
</div>
<!-- Format Dropdown -->
<div class="flex items-center gap-3">
<div class="w-48">
<AppSelect
v-model="analysisStore.options.output_format"
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
/>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row items-center justify-between gap-4 bg-gray-50 dark:bg-zinc-800/50 p-4 rounded-xl">
<div class="flex items-center gap-2 w-full md:w-auto">
<button @click="analysisStore.setAllEntries(true)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">全选</button>
<button @click="analysisStore.setAllEntries(false)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">取消全选</button>
<button @click="analysisStore.invertSelection()" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">反选</button>
</div>
</div>
<!-- Video List (Playlist Mode) -->
<div v-if="analysisStore.metadata.entries" class="max-h-[500px] overflow-y-auto p-2 space-y-2 bg-gray-50/50 dark:bg-black/20">
<div
v-for="entry in analysisStore.metadata.entries"
:key="entry.id"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white dark:hover:bg-zinc-800 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-zinc-700 group"
:class="entry.selected ? 'opacity-100' : 'opacity-60 grayscale'"
>
<!-- Checkbox -->
<button
@click="entry.selected = !entry.selected"
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-colors shrink-0"
:class="entry.selected ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-300 dark:border-zinc-600 hover:border-blue-400'"
<div class="flex items-center gap-6 w-full md:w-auto justify-end">
<div class="flex items-center gap-x-4 p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
<span class="font-medium text-base text-zinc-700 dark:text-gray-300">仅音频</span>
<button
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
>
<svg v-if="entry.selected" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
</div>
<!-- Thumb -->
<img :src="entry.thumbnail || '/placeholder.png'" class="w-24 h-14 object-cover rounded-lg bg-gray-200 dark:bg-zinc-700 shrink-0" />
<!-- Info -->
<div class="flex-1 min-w-0">
<h4 class="font-medium text-zinc-900 dark:text-white truncate text-sm" :title="entry.title">{{ entry.title }}</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ entry.duration ? Math.floor(entry.duration / 60) + ':' + String(Math.floor(entry.duration % 60)).padStart(2, '0') : '' }}
<span v-if="entry.uploader" class="mx-1"></span> {{ entry.uploader }}
</p>
</div>
</div>
</div>
<!-- Single Video Layout -->
<div v-else class="p-6 flex flex-col md:flex-row gap-6">
<!-- Thumbnail -->
<img :src="analysisStore.metadata.thumbnail || '/placeholder.png'" class="w-full md:w-64 aspect-video object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
<!-- Details -->
<div class="flex-1">
<h2 class="text-xl font-bold text-zinc-900 dark:text-white line-clamp-2">{{ analysisStore.metadata.title }}</h2>
<p v-if="analysisStore.metadata.uploader" class="text-gray-500 dark:text-gray-400 mt-1">{{ analysisStore.metadata.uploader }}</p>
<p v-if="analysisStore.metadata.entries" class="text-blue-500 mt-1 font-medium">{{ analysisStore.metadata.entries.length }} 个视频 (播放列表)</p>
<!-- Options -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<!-- Audio Only Toggle -->
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
<span class="font-medium text-base">仅音频</span>
<button
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
</div>
<!-- Quality Dropdown -->
<div class="relative">
<AppSelect
<div class="flex items-center gap-3">
<div class="w-44">
<AppSelect
v-model="analysisStore.options.quality"
:options="qualityOptions"
:disabled="analysisStore.options.is_audio_only"
/>
</div>
</div>
</div>
<!-- Format Dropdown -->
<div class="relative">
<AppSelect
<div class="flex items-center gap-3">
<div class="w-48">
<AppSelect
v-model="analysisStore.options.output_format"
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="playlistMetadata" class="max-h-[500px] overflow-y-auto p-2 space-y-2 bg-gray-50/50 dark:bg-black/20">
<div
v-for="entry in playlistMetadata.entries"
:key="entry.id"
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white dark:hover:bg-zinc-800 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-zinc-700 group"
:class="entry.selected ? 'opacity-100' : 'opacity-60 grayscale'"
>
<button
@click="entry.selected = !entry.selected"
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-colors shrink-0"
:class="entry.selected ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-300 dark:border-zinc-600 hover:border-blue-400'"
>
<svg v-if="entry.selected" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<img :src="entry.thumbnail || '/placeholder.png'" class="w-24 h-14 object-cover rounded-lg bg-gray-200 dark:bg-zinc-700 shrink-0" />
<div class="flex-1 min-w-0">
<h4 class="font-medium text-zinc-900 dark:text-white truncate text-sm" :title="entry.title">{{ entry.title }}</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ entry.duration ? Math.floor(entry.duration / 60) + ':' + String(Math.floor(entry.duration % 60)).padStart(2, '0') : '' }}
<span v-if="entry.uploader" class="mx-1"></span> {{ entry.uploader }}
</p>
</div>
</div>
</div>
<div v-else-if="singleMetadata" class="p-6 flex flex-col md:flex-row gap-6">
<img :src="singleMetadata.thumbnail || '/placeholder.png'" class="w-full md:w-64 aspect-video object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
<div class="flex-1">
<h2 class="text-xl font-bold text-zinc-900 dark:text-white line-clamp-2">{{ singleMetadata.title }}</h2>
<p v-if="singleMetadata.uploader" class="text-gray-500 dark:text-gray-400 mt-1">{{ singleMetadata.uploader }}</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
<span class="font-medium text-base">仅音频</span>
<button
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
>
<span
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
</div>
<div class="relative">
<AppSelect
v-model="analysisStore.options.quality"
:options="qualityOptions"
:disabled="analysisStore.options.is_audio_only"
/>
</div>
<div class="relative">
<AppSelect
v-model="analysisStore.options.output_format"
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
/>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-zinc-800/50 border-t border-gray-200 dark:border-zinc-800 flex justify-end">
<button
@click="startDownload"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold transition-colors shadow-lg shadow-blue-600/20"
>
立即下载 {{ analysisStore.metadata.entries ? `(${analysisStore.metadata.entries.filter((e: any) => e.selected).length})` : '' }}
</button>
<button
@click="startDownload"
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold transition-colors shadow-lg shadow-blue-600/20"
>
立即下载 {{ selectedCount > 0 ? `(${selectedCount})` : '' }}
</button>
</div>
</div>
<!-- Active Downloads -->
<div v-if="queueStore.tasks.length > 0">
<h3 class="text-lg font-bold mb-4">进行中的任务</h3>
<div class="space-y-3">
<!-- Reversed to show newest first -->
<div v-for="task in queueStore.tasks.slice().reverse()" :key="task.id" class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4">
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
<div class="flex-1 min-w-0">
<div class="flex justify-between mb-1">
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
{{ task.status === 'finished' ? '已完成' : (task.status === 'error' ? '失败' : task.speed) }}
</span>
</div>
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:style="{ width: `${task.progress}%` }"
:class="task.status === 'error' ? 'bg-red-500' : (task.status === 'finished' ? 'bg-green-500' : 'bg-blue-600')"
/>
</div>
</div>
<h3 class="text-lg font-bold mb-4">进行中的任务</h3>
<div class="space-y-3">
<div v-for="task in queueStore.tasks.slice().reverse()" :key="task.id" class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4">
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
<div class="flex-1 min-w-0">
<div class="flex justify-between mb-1">
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
{{ task.status === 'finished' ? '已完成' : (task.status === 'error' ? '失败' : task.speed) }}
</span>
</div>
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:style="{ width: `${task.progress}%` }"
:class="task.status === 'error' ? 'bg-red-500' : (task.status === 'finished' ? 'bg-green-500' : 'bg-blue-600')"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</template>

View File

@@ -210,7 +210,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
</div>
<div>
<div class="font-medium text-zinc-900 dark:text-white">QuickJS</div>
<div class="text-xs text-gray-500 mt-0.5 font-mono">{{ settingsStore.quickjsVersion }}</div>
<div class="text-xs text-gray-500 mt-0.5 font-mono" :title="settingsStore.quickjsVersion">{{ settingsStore.quickjsVersion }}</div>
</div>
</div>
<button
@@ -254,4 +254,4 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
</section>
</div>
</div>
</template>
</template>