chore: add CONTRIBUTING.md for project guidelines and enhance README with author follow section

This commit is contained in:
Nexmoe
2025-10-24 19:22:37 +08:00
parent 8ad41eebe2
commit 4dfac6bb34
18 changed files with 808 additions and 580 deletions

View File

@@ -30,19 +30,6 @@ jobs:
- name: Install Dependencies
run: pnpm install
- name: Download yt-dlp binaries
run: |
# Download yt-dlp.exe for Windows
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
# Download yt-dlp for macOS (for cross-platform builds)
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
# chmod +x resources/yt-dlp_macos
# Download yt-dlp for Linux (for cross-platform builds)
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
chmod +x resources/yt-dlp_linux
- name: Type check
run: pnpm run typecheck

77
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,77 @@
# Contributing to VidBee
Thank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.
## Getting Ready
- Use Node.js 18+ and pnpm 8+.
- Install dependencies with `pnpm install`.
- Run `pnpm dev` to test changes locally.
## Tech Stack
- Runtime: Electron 38, electron-vite, electron-builder.
- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
## Local Development
- Use `pnpm install` to pull dependencies after cloning.
- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.
- Preview the production build locally with `pnpm start`.
## Useful Scripts
| Command | Purpose |
| --- | --- |
| `pnpm run typecheck` | Type-check the main and renderer projects. |
| `pnpm build` | Run type checks and produce production bundles. |
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
| `pnpm build:unpack` | Produce unpacked output directories for inspection. |
| `pnpm run check` | Format and lint the codebase with Biome. |
## Project Structure
```text
src/
|-- main/ # Electron main process, IPC services, configuration
|-- preload/ # Context bridge and preload helpers
`-- renderer/
|-- src/
| |-- pages/ # Application routes (Home, Settings, Playlist, etc.)
| |-- components/ # UI components, download views, shared controls
| |-- data/ # Static datasets such as popularSites.ts
| |-- hooks/ # Custom hooks and global atoms
| |-- lib/ # Utilities shared across the renderer
| `-- assets/ # Global styles and icons
`-- index.html
```
## Internationalization
- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.
- Only update strings in `src/renderer/src/locales/en.json`; maintainers handle the other locales.
- Keep copy edits focused and avoid removing translation keys without discussion.
## Configuration and Storage
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
- Logs are recorded with `electron-log` to simplify troubleshooting.
## Packaging
- Build production bundles with `pnpm build`.
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
## Working on Changes
- Keep each pull request focused on a single problem or feature.
- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.
- Write comments and console messages in English only.
- When updating copy in the app, adjust strings in `src/renderer/src/locales/en.json`; other locale files are handled by maintainers.
## Opening Issues
- Search existing issues to avoid duplicates.
- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.
## Submitting Pull Requests
- Explain the motivation and impact of the change in the description.
- Mention any user facing updates or migrations.
- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.
We appreciate every contribution that keeps VidBee simple and reliable.

132
README.md
View File

@@ -14,6 +14,8 @@
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
- Localized interface support in many languages
### 🎨 Best-in-class UI Experience
@@ -22,11 +24,6 @@
- **Real-time Progress** - Detailed download progress tracking and status management
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
### ⚡ Powerful Features
- 🎯 Automatically detect platform-friendly formats and store files in custom locations
- 🎨 Localized interface support in many languages
## 📥 Download & Install
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
@@ -48,116 +45,27 @@
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
### 🎬 Video Platforms
- **📺 YouTube** - Long-form and livestream video from creators worldwide
- **🎵 TikTok** - Short-form mobile videos, effects, and live streams
- **📘 Facebook** - Feed, Watch, and Reels videos from public pages
- **📷 Instagram** - Feed, Stories, Reels, and Highlights content
- **🐦 X (Twitter)** - Timeline posts, Spaces recordings, and broadcasts
- **🎥 Vimeo** - High-quality creator and business video hosting
- **🌍 Dailymotion** - Global news, sports, and entertainment clips
- **🎮 Twitch** - Gaming, music, and IRL live streams and VODs
- **💼 LinkedIn** - Professional talks, webinars, and learning videos
- **📌 Pinterest** - Idea pins, how-to reels, and lifestyle inspiration videos
- **🎨 Tumblr** - Creative short-form media and fan edits
- **🇯🇵 Niconico** - Japanese animation, music, and live broadcast archive
- **⚡ Kick** - Creator live streams and replays on the Kick platform
### 🎵 Audio Platforms
- **🎶 YouTube Music** - Official music videos, albums, and live performances
- **🎧 SoundCloud** - Music tracks, playlists, and DJ sets
- **🎛️ Mixcloud** - DJ mixes, radio shows, and long-form audio
- **🎸 Bandcamp** - Independent artist albums and community releases
### 🔗 Other Platforms
- **🤖 Reddit** - Embedded clips and hosted videos from communities
| Video Platforms | Audio & Other Platforms |
| --- | --- |
| YouTube | YouTube Music |
| TikTok | SoundCloud |
| Facebook | Mixcloud |
| Instagram | Bandcamp |
| X (Twitter) | Reddit |
| Vimeo | |
| Dailymotion | |
| Twitch | |
| LinkedIn | |
| Pinterest | |
| Tumblr | |
| Niconico | |
| Kick | |
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
## 🛠️ Tech Stack
## ?? Contributing
- **Runtime:** Electron 38, electron-vite, electron-builder
- **Frontend:** React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons
- **Tooling:** TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes
## 🛠️ Development Setup
### 📋 Prerequisites
- Node.js 18 or newer
- pnpm 8 or newer
### 📦 Install dependencies
```bash
pnpm install
```
### 🏃‍♂️ Run the app in development
```bash
pnpm dev
```
The Electron app and Vite dev server launch together with hot module replacement.
## 📜 Useful Scripts
| Command | Description |
| --- | --- |
| `pnpm dev` | Run the Electron and Vite development environment |
| `pnpm start` | Preview the production build locally |
| `pnpm run typecheck` | Type-check the main and renderer projects |
| `pnpm build` | Run type checks and produce production bundles |
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables |
| `pnpm build:unpack` | Produce unpacked output directories |
| `pnpm run check` | Format and lint the codebase with Biome |
## 📁 Project Structure
```text
src/
├─ main/ # Electron main process, IPC services, configuration
├─ preload/ # Context bridge and preload helpers
└─ renderer/
├─ src/
│ ├─ pages/ # Application routes (Home, Settings, Playlist, etc.)
│ ├─ components/ # UI components, download views, shared controls
│ ├─ data/ # Static datasets such as popularSites.ts
│ ├─ hooks/ # Custom hooks and global atoms
│ ├─ lib/ # Utilities shared across the renderer
│ └─ assets/ # Global styles and icons
└─ index.html
```
## 🌍 Internationalization
The renderer uses i18next with English (`en`) and Simplified Chinese (`zh-CN`) namespaces. Update strings in `src/renderer/src/locales/en.json`; other locales are maintained separately.
## ⚙️ Configuration and Storage
- Persistent settings are stored with `electron-store` and exposed through IPC helpers
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services
- Logs are recorded with `electron-log` to simplify troubleshooting
## 📦 Packaging
Run one of the following commands after a successful build:
```bash
pnpm build:win
pnpm build:mac
pnpm build:linux
```
Artifacts are generated under `dist/`. Use `pnpm build:unpack` to create unpacked directories for manual inspection.
## 🤝 Contributing
Issues and pull requests are welcome. Keep changes focused, document user facing updates, and run `pnpm run check` before opening a PR.
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on reporting issues, proposing features, and opening pull requests. It also covers the tech stack, development workflow, scripts, internationalization notes, configuration guidance, and packaging steps.
## 📄 License
@@ -165,9 +73,9 @@ This project is distributed under the MIT License. See `LICENSE` for details.
## 🙏 Thanks
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [Electron](https://www.electronjs.org/)
- [React](https://react.dev/)
- [Vite](https://vitejs.dev/)
- [Tailwind CSS](https://tailwindcss.com/)
- [shadcn/ui](https://ui.shadcn.com/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)

View File

@@ -11,7 +11,7 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",

View File

@@ -0,0 +1,28 @@
/**
* 设置控制台编码为 UTF-8解决中文乱码问题
* 这个脚本在 Windows 上设置控制台代码页为 UTF-8
*/
const { exec } = require('node:child_process')
const os = require('node:os')
if (os.platform() === 'win32') {
console.log('Setting console encoding to UTF-8...')
// 设置控制台代码页为 UTF-8 (65001)
exec('chcp 65001', (error, stdout, stderr) => {
if (error) {
console.warn('Failed to set console code page:', error)
return
}
if (stderr) {
console.warn('Console code page setting warning:', stderr)
}
console.log('Console encoding set to UTF-8')
console.log('Output:', stdout)
})
} else {
console.log('Not on Windows, no console encoding change needed')
}

View File

@@ -0,0 +1,44 @@
import log from 'electron-log/main'
/**
* Configure electron-log
* Set log format, file path, transport methods, etc.
*/
export function configureLogger() {
// Configure console output format - support colors and scope, time in gray
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
// Enable console colors
log.transports.console.useStyles = true
// Configure file output format - include scope information
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
// Set log levels
// Development: show all logs
// Production: show info level and above only
const isDev = process.env.NODE_ENV === 'development'
log.transports.console.level = isDev ? 'silly' : 'info'
log.transports.file.level = isDev ? 'silly' : 'info'
// Enable IPC transport in development environment to show renderer process logs in main process console
if (isDev) {
log.transports.ipc.level = 'silly'
} else {
log.transports.ipc.level = false
}
// Set maximum log file size (10MB)
log.transports.file.maxSize = 10 * 1024 * 1024
// Enable error catching - catch unhandled errors and rejected promises
log.errorHandler.startCatching({
showDialog: false, // Don't show error dialog, only log to file
onError: (options) => {
log.error('Unhandled error caught by electron-log:', options.error)
log.error('App versions:', options.versions)
}
})
log.info('Log file location:', log.transports.file.getFile().path)
}

View File

@@ -2,225 +2,26 @@ import { EventEmitter } from 'node:events'
import path from 'node:path'
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
import type {
AppSettings,
DownloadHistoryItem,
DownloadItem,
DownloadOptions,
DownloadProgress,
OneClickQualityPreset,
PlaylistDownloadOptions,
PlaylistInfo,
VideoFormat,
VideoInfo
} from '../shared/types'
import { buildDownloadArgs } from './download-engine/args-builder'
import {
findFormatByIdCandidates,
parseSizeToBytes,
resolveSelectedFormat
} from './download-engine/format-utils'
import { DownloadQueue } from './lib/download-queue'
import { historyManager } from './lib/history-manager'
import { ytdlpManager } from './lib/ytdlp-manager'
import { settingsManager } from './settings'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
bad: 128,
worst: 96
}
const selectVideoFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const heightDiff = (b.height ?? 0) - (a.height ?? 0)
if (heightDiff !== 0) return heightDiff
const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)
if (fpsDiff !== 0) return fpsDiff
return (b.tbr ?? 0) - (a.tbr ?? 0)
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const heightLimit = qualityPresetToVideoHeight[preset]
if (!heightLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const height = format.height ?? 0
return height > 0 && height <= heightLimit
})
return withinLimit ?? sorted[0]
}
const selectAudioFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)
if (bitrateDiff !== 0) return bitrateDiff
const sizeA = a.filesize ?? a.filesize_approx ?? 0
const sizeB = b.filesize ?? b.filesize_approx ?? 0
if (sizeB !== sizeA) return sizeB - sizeA
return 0
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const abrLimit = qualityPresetToAudioAbr[preset]
if (!abrLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const bitrate = format.tbr ?? 0
return bitrate > 0 && bitrate <= abrLimit
})
return withinLimit ?? sorted[0]
}
const findFormatBySelector = (
formats: VideoFormat[],
selector?: string
): VideoFormat | undefined => {
if (!selector) {
return undefined
}
const candidateIds = selector
.split('/')
.map((option) => option.split('+')[0].trim())
.filter((option) => option.length > 0)
for (const candidateId of candidateIds) {
const match = formats.find((format) => format.format_id === candidateId)
if (match) {
return match
}
}
return undefined
}
const findFormatByIdCandidates = (
formats: VideoFormat[],
rawFormatId: string | undefined
): VideoFormat | undefined => {
if (!rawFormatId) {
return undefined
}
const parts = rawFormatId
.split('+')
.map((part) => part.trim())
.filter((part) => part.length > 0)
for (const part of parts) {
const match = formats.find((format) => format.format_id === part)
if (match) {
return match
}
}
return undefined
}
const parseSizeToBytes = (value?: string): number | undefined => {
if (!value) {
return undefined
}
const cleaned = value.trim().replace(/^~\s*/, '')
if (!cleaned) {
return undefined
}
const match = cleaned.match(/^([\d.,]+)\s*([KMGTP]?i?B)$/i)
if (!match) {
return undefined
}
const amount = Number(match[1].replace(/,/g, ''))
if (Number.isNaN(amount)) {
return undefined
}
const unit = match[2].toUpperCase()
const multipliers: Record<string, number> = {
B: 1,
KB: 1_000,
KIB: 1_024,
MB: 1_000_000,
MIB: 1_048_576,
GB: 1_000_000_000,
GIB: 1_073_741_824,
TB: 1_000_000_000_000,
TIB: 1_099_511_627_776
}
const multiplier = multipliers[unit]
if (!multiplier) {
return undefined
}
return Math.round(amount * multiplier)
}
const resolveSelectedFormat = (
formats: VideoFormat[],
options: DownloadOptions,
settings: AppSettings
): VideoFormat | undefined => {
const directMatch = findFormatBySelector(formats, options.format)
if (directMatch) {
return directMatch
}
const preset = settings.oneClickQuality ?? 'auto'
if (options.type === 'video') {
const videoFormats = formats.filter(
(format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'
)
return selectVideoFormatForPreset(videoFormats, preset)
}
if (options.type === 'audio' || options.type === 'extract') {
const audioFormats = formats.filter(
(format) =>
!!format.acodec &&
format.acodec !== 'none' &&
(!format.video_ext || format.video_ext === 'none')
)
return selectAudioFormatForPreset(audioFormats, preset)
}
return undefined
}
import { scopedLoggers } from './utils/logger'
interface DownloadProcess {
controller: AbortController
@@ -284,16 +85,27 @@ class DownloadEngine extends EventEmitter {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout)
scopedLoggers.download.info('Successfully retrieved video info for:', url)
resolve(info)
} catch (error) {
scopedLoggers.download.error('Failed to parse video info for:', url, error)
reject(new Error(`Failed to parse video info: ${error}`))
}
} else {
scopedLoggers.download.error(
'Failed to fetch video info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
reject(new Error(stderr || 'Failed to fetch video info'))
}
})
process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error for:', url, error)
reject(error)
})
})
@@ -373,7 +185,7 @@ class DownloadEngine extends EventEmitter {
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
console.log(
scopedLoggers.download.info(
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"`
)
@@ -440,6 +252,7 @@ class DownloadEngine extends EventEmitter {
}
private async executeDownload(id: string, options: DownloadOptions): Promise<void> {
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const downloadPath = options.outputPath || settings.downloadPath
@@ -455,12 +268,14 @@ class DownloadEngine extends EventEmitter {
let actualFormat: string | null = null
let actualQuality: string | null = null
let actualCodec: string | null = null
let videoInfo: VideoInfo | undefined
// First, get detailed video info to capture basic metadata and formats
try {
const videoInfo = await this.getVideoInfo(options.url)
const info = await this.getVideoInfo(options.url)
videoInfo = info
availableFormats = Array.isArray(videoInfo.formats) ? videoInfo.formats : []
availableFormats = Array.isArray(info.formats) ? info.formats : []
selectedFormat = resolveSelectedFormat(availableFormats, options, settings)
if (selectedFormat) {
@@ -482,28 +297,28 @@ class DownloadEngine extends EventEmitter {
}
this.updateDownloadInfo(id, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
uploader: videoInfo.uploader,
viewCount: videoInfo.view_count,
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
description: info.description,
uploader: info.uploader,
viewCount: info.view_count,
// Store only essential download info
selectedFormat
})
this.upsertHistoryEntry(id, options, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
uploader: videoInfo.uploader,
viewCount: videoInfo.view_count,
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
description: info.description,
uploader: info.uploader,
viewCount: info.view_count,
// Store only essential download info
selectedFormat
})
} catch (error) {
console.warn('Failed to get detailed video info:', error)
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
}
const applySelectedFormat = (formatId: string | undefined): boolean => {
@@ -542,7 +357,7 @@ class DownloadEngine extends EventEmitter {
return true
}
const args = this.buildDownloadArgs(options, downloadPath, settings)
const args = buildDownloadArgs(options, downloadPath, settings)
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
@@ -598,6 +413,7 @@ class DownloadEngine extends EventEmitter {
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
// Look for download destination messages
scopedLoggers.download.info('ytDlpEvent:', eventType, eventData)
if (eventType === 'download' && eventData.includes('Destination:')) {
const match = eventData.match(/Destination:\s*(.+)/)
if (match?.[1]) {
@@ -668,113 +484,47 @@ class DownloadEngine extends EventEmitter {
this.queue.downloadCompleted(id)
if (code === 0) {
// Try to get the actual output path from yt-dlp events first
let finalOutputPath = actualOutputPath
// If we don't have the actual path, try to construct it
if (!finalOutputPath) {
try {
// Get video info to construct the expected output path
const videoInfo = await this.getVideoInfo(options.url)
const title = videoInfo.title || 'Unknown'
// Sanitize title for filename - handle Chinese characters and special chars
const sanitizedTitle = title
.replace(/[<>:"/\\|?*]/g, '_')
.replace(/[\u4e00-\u9fff]/g, '') // Remove Chinese characters
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.substring(0, 50) // Shorter limit for safety
// Determine file extension based on format
let extension = 'mp4' // default
if (options.type === 'audio') {
extension = options.extractFormat || 'mp3'
} else if (actualFormat) {
extension = actualFormat
}
// Construct the expected output path
const expectedFileName = `${sanitizedTitle}.${extension}`
const expectedPath = path.join(downloadPath, expectedFileName)
// Check if the file exists
const fs = await import('node:fs/promises')
try {
await fs.access(expectedPath)
finalOutputPath = expectedPath
} catch {
// Try to find any file with similar name in the download directory
try {
const files = await fs.readdir(downloadPath)
const matchingFile = files.find((file) => {
// Look for files that might match our download
const isVideoFile =
file.endsWith('.mp4') ||
file.endsWith('.webm') ||
file.endsWith('.mkv') ||
file.endsWith('.avi') ||
file.endsWith('.mov')
const isAudioFile =
file.endsWith('.mp3') ||
file.endsWith('.m4a') ||
file.endsWith('.aac') ||
file.endsWith('.ogg')
const isCorrectType =
(options.type === 'video' && isVideoFile) ||
(options.type === 'audio' && isAudioFile)
// Check if file was created recently (within last 5 minutes)
const filePath = path.join(downloadPath, file)
try {
const fsSync = require('node:fs')
const stats = fsSync.statSync(filePath)
const isRecent = Date.now() - stats.mtime.getTime() < 5 * 60 * 1000
return isCorrectType && isRecent
} catch {
return false
}
})
if (matchingFile) {
finalOutputPath = path.join(downloadPath, matchingFile)
}
} catch (error) {
console.warn('Failed to search for matching files:', error)
}
}
} catch (error) {
console.warn('Failed to construct output path:', error)
}
// Use actual output path from yt-dlp, or fallback to simple generated path
let finalOutputPath: string
if (actualOutputPath) {
finalOutputPath = actualOutputPath
scopedLoggers.download.info(
'Using actual output path from yt-dlp for ID:',
id,
'Path:',
finalOutputPath
)
} else {
// Simple fallback: generate path based on video title and format
const title = videoInfo?.title || 'Unknown'
const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50)
const extension =
options.type === 'audio' ? options.extractFormat || 'mp3' : actualFormat || 'mp4'
const fileName = `${sanitizedTitle}.${extension}`
finalOutputPath = path.join(downloadPath, fileName)
scopedLoggers.download.warn(
'Using fallback output path for ID:',
id,
'Path:',
finalOutputPath
)
}
// Fallback to default path if still no output path
if (!finalOutputPath) {
// Create a generic filename with timestamp
const timestamp = Date.now()
const extension = options.type === 'audio' ? options.extractFormat || 'mp3' : 'mp4'
const genericFileName = `download_${timestamp}.${extension}`
finalOutputPath = path.join(downloadPath, genericFileName)
}
// Get file size if we have the output path
let fileSize: number | undefined
let fileSizeError: unknown
if (finalOutputPath) {
try {
const fs = await import('node:fs/promises')
const stats = await fs.stat(finalOutputPath)
fileSize = stats.size
} catch (error) {
fileSizeError = error
try {
const fs = await import('node:fs/promises')
const stats = await fs.stat(finalOutputPath)
fileSize = stats.size
} catch (error) {
if (latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
} else {
scopedLoggers.download.warn('Failed to get file size for ID:', id, error)
}
}
if (fileSize === undefined && latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
} else if (fileSize === undefined && fileSizeError) {
console.warn('Failed to get file size:', fileSizeError)
}
this.updateDownloadInfo(id, {
@@ -786,9 +536,16 @@ class DownloadEngine extends EventEmitter {
quality: actualQuality || undefined,
codec: actualCodec || undefined
})
scopedLoggers.download.info('Download completed successfully for ID:', id)
this.emit('download-completed', id)
this.addToHistory(id, options, 'completed', undefined, finalOutputPath)
} else {
scopedLoggers.download.error(
'Download failed with exit code for ID:',
id,
'Exit code:',
code
)
this.emit('download-error', id, new Error(`Download exited with code ${code}`))
this.addToHistory(id, options, 'error', `Download exited with code ${code}`)
}
@@ -796,6 +553,7 @@ class DownloadEngine extends EventEmitter {
// Handle errors
ytdlpProcess.on('error', (error: Error) => {
scopedLoggers.download.error('Download process error for ID:', id, error)
this.activeDownloads.delete(id)
this.queue.downloadCompleted(id)
this.emit('download-error', id, error)
@@ -803,112 +561,8 @@ class DownloadEngine extends EventEmitter {
})
}
private buildDownloadArgs(
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
): string[] {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Format selection
if (options.type === 'video') {
args.push('-f', this.resolveVideoFormatSelector(options))
} else if (options.type === 'audio') {
args.push('-f', this.resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
args.push('-x')
args.push('--audio-format', options.extractFormat || 'mp3')
args.push('--audio-quality', options.extractQuality || '5')
}
// Time range
if (options.startTime || options.endTime) {
const start = options.startTime || '0'
const end = options.endTime || ''
args.push('--download-sections', `*${start}-${end || ''}`)
}
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
}
// Output path with proper encoding handling
// Use sanitized filename to avoid encoding issues
const outputTemplate = path.join(downloadPath, '%(title).100s.%(ext)s')
args.push('-o', outputTemplate)
// Add options for better filename handling on Windows
if (process.platform === 'win32') {
// On Windows, use a more conservative approach to avoid encoding issues
args.push('--windows-filenames') // Use Windows-compatible filenames
}
// Browser cookies
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
// Proxy
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Config file
if (settings.configPath) {
args.push('--config-location', settings.configPath)
}
// URL (must be last)
args.push(options.url)
return args
}
private resolveVideoFormatSelector(options: DownloadOptions): string {
const format = options.format
const audioFormat = options.audioFormat
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
if (!format || format === 'best') {
if (audioFormat === 'none') {
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
}
return `bestvideo+${audioFormat}`
}
if (audioFormat === 'none') {
return `${format}+none`
}
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
return `${format}+${audio}`
}
private resolveAudioFormatSelector(options: DownloadOptions): string {
const format = options.format
if (!format) {
return 'bestaudio'
}
if (format.includes('/') || format.includes('+') || format.includes('[')) {
return format
}
return format
}
cancelDownload(id: string): boolean {
scopedLoggers.download.info('Cancelling download for ID:', id)
const snapshot = this.queue.getItemDetails(id)
const download = this.activeDownloads.get(id)
@@ -916,6 +570,7 @@ class DownloadEngine extends EventEmitter {
download.controller.abort()
const removedFromQueue = this.queue.remove(id)
this.activeDownloads.delete(id)
scopedLoggers.download.info('Download cancelled successfully for ID:', id)
this.emit('download-cancelled', id)
if (snapshot) {
this.upsertHistoryEntry(id, snapshot.options, {
@@ -1019,14 +674,15 @@ class DownloadEngine extends EventEmitter {
): void {
// Get the download item from the queue to get additional info
const completedDownload = this.queue.getCompletedDownload(id)
scopedLoggers.download.info('Completed download:', completedDownload)
const completedAt = Date.now()
const finalOutputPath = actualOutputPath || options.outputPath
this.upsertHistoryEntry(id, options, {
title: completedDownload?.item.title || `Download ${id}`,
thumbnail: completedDownload?.item.thumbnail,
status,
outputPath: actualOutputPath || options.outputPath,
outputPath: finalOutputPath,
completedAt,
error,
duration: completedDownload?.item.duration,

View File

@@ -0,0 +1,104 @@
import path from 'node:path'
import type { AppSettings, DownloadOptions } from '../../shared/types'
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
const format = options.format
const audioFormat = options.audioFormat
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
if (!format || format === 'best') {
if (audioFormat === 'none') {
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
}
return `bestvideo+${audioFormat}`
}
if (audioFormat === 'none') {
return `${format}+none`
}
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
return `${format}+${audio}`
}
export const resolveAudioFormatSelector = (options: DownloadOptions): string => {
const format = options.format
if (!format) {
return 'bestaudio'
}
if (format.includes('/') || format.includes('+') || format.includes('[')) {
return format
}
return format
}
export const buildDownloadArgs = (
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Format selection
if (options.type === 'video') {
args.push('-f', resolveVideoFormatSelector(options))
} else if (options.type === 'audio') {
args.push('-f', resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
args.push('-x')
args.push('--audio-format', options.extractFormat || 'mp3')
args.push('--audio-quality', options.extractQuality || '5')
}
// Time range
if (options.startTime || options.endTime) {
const start = options.startTime || '0'
const end = options.endTime || ''
args.push('--download-sections', `*${start}-${end || ''}`)
}
// Subtitles
if (options.downloadSubs) {
args.push('--write-subs', '--sub-langs', 'all')
}
// Output path with proper encoding handling
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
args.push('-o', outputTemplate)
// Add options for better filename handling
args.push('--no-part')
args.push('--no-playlist-reverse')
if (process.platform === 'win32') {
args.push('--windows-filenames')
}
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
if (settings.configPath) {
args.push('--config-location', settings.configPath)
}
args.push(options.url)
return args
}

View File

@@ -0,0 +1,210 @@
import type {
AppSettings,
DownloadOptions,
OneClickQualityPreset,
VideoFormat
} from '../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
bad: 128,
worst: 96
}
const selectVideoFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const heightDiff = (b.height ?? 0) - (a.height ?? 0)
if (heightDiff !== 0) return heightDiff
const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)
if (fpsDiff !== 0) return fpsDiff
return (b.tbr ?? 0) - (a.tbr ?? 0)
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const heightLimit = qualityPresetToVideoHeight[preset]
if (!heightLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const height = format.height ?? 0
return height > 0 && height <= heightLimit
})
return withinLimit ?? sorted[0]
}
const selectAudioFormatForPreset = (
formats: VideoFormat[],
preset: OneClickQualityPreset
): VideoFormat | undefined => {
if (formats.length === 0) {
return undefined
}
const sorted = [...formats].sort((a, b) => {
const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)
if (bitrateDiff !== 0) return bitrateDiff
const sizeA = a.filesize ?? a.filesize_approx ?? 0
const sizeB = b.filesize ?? b.filesize_approx ?? 0
if (sizeB !== sizeA) return sizeB - sizeA
return 0
})
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
const abrLimit = qualityPresetToAudioAbr[preset]
if (!abrLimit) {
return sorted[0]
}
const withinLimit = sorted.find((format) => {
const bitrate = format.tbr ?? 0
return bitrate > 0 && bitrate <= abrLimit
})
return withinLimit ?? sorted[0]
}
const findFormatBySelector = (
formats: VideoFormat[],
selector?: string
): VideoFormat | undefined => {
if (!selector) {
return undefined
}
const candidateIds = selector
.split('/')
.map((option) => option.split('+')[0].trim())
.filter((option) => option.length > 0)
for (const candidateId of candidateIds) {
const match = formats.find((format) => format.format_id === candidateId)
if (match) {
return match
}
}
return undefined
}
export const findFormatByIdCandidates = (
formats: VideoFormat[],
rawFormatId: string | undefined
): VideoFormat | undefined => {
if (!rawFormatId) {
return undefined
}
const parts = rawFormatId
.split('+')
.map((part) => part.trim())
.filter((part) => part.length > 0)
for (const part of parts) {
const match = formats.find((format) => format.format_id === part)
if (match) {
return match
}
}
return undefined
}
export const parseSizeToBytes = (value?: string): number | undefined => {
if (!value) {
return undefined
}
const cleaned = value.trim().replace(/^~\s*/, '')
if (!cleaned) {
return undefined
}
const match = cleaned.match(/^([\d.,]+)\s*([KMGTP]?i?B)$/i)
if (!match) {
return undefined
}
const amount = Number(match[1].replace(/,/g, ''))
if (Number.isNaN(amount)) {
return undefined
}
const unit = match[2].toUpperCase()
const multipliers: Record<string, number> = {
B: 1,
KB: 1_000,
KIB: 1_024,
MB: 1_000_000,
MIB: 1_048_576,
GB: 1_000_000_000,
GIB: 1_073_741_824,
TB: 1_000_000_000_000,
TIB: 1_099_511_627_776
}
const multiplier = multipliers[unit]
if (!multiplier) {
return undefined
}
return Math.round(amount * multiplier)
}
export const resolveSelectedFormat = (
formats: VideoFormat[],
options: DownloadOptions,
settings: AppSettings
): VideoFormat | undefined => {
const directMatch = findFormatBySelector(formats, options.format)
if (directMatch) {
return directMatch
}
const preset = settings.oneClickQuality ?? 'auto'
if (options.type === 'video') {
const videoFormats = formats.filter(
(format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'
)
return selectVideoFormatForPreset(videoFormats, preset)
}
if (options.type === 'audio' || options.type === 'extract') {
const audioFormats = formats.filter(
(format) =>
!!format.acodec &&
format.acodec !== 'none' &&
(!format.video_ext || format.video_ext === 'none')
)
return selectAudioFormatForPreset(audioFormats, preset)
}
return undefined
}

View File

@@ -1,15 +1,22 @@
import { join } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import log from 'electron-log'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
import { configureLogger } from './config/logger-config'
import { downloadEngine } from './download-engine'
import { services } from './ipc'
import { ytdlpManager } from './lib/ytdlp-manager'
import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
// Initialize electron-log for main process
log.initialize()
// Configure logger settings
configureLogger()
let mainWindow: BrowserWindow | null = null
let isQuitting = false
@@ -165,15 +172,15 @@ app.whenReady().then(async () => {
})
// IPC services are automatically registered by electron-ipc-decorator when imported
console.log('IPC services available:', Object.keys(services))
log.info('IPC services available:', Object.keys(services))
// Initialize yt-dlp
try {
console.log('Initializing yt-dlp...')
log.info('Initializing yt-dlp...')
await ytdlpManager.initialize()
console.log('yt-dlp initialized successfully')
log.info('yt-dlp initialized successfully')
} catch (error) {
console.error('Failed to initialize yt-dlp:', error)
log.error('Failed to initialize yt-dlp:', error)
}
createWindow()

View File

@@ -1,10 +1,15 @@
import { execFile } from 'node:child_process'
import type { Dirent } from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { dialog, shell } from 'electron'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { clipboard, dialog, shell } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
const execFileAsync = promisify(execFile)
class FileSystemService extends IpcService {
static readonly groupName = 'fs'
@@ -109,10 +114,48 @@ class FileSystemService extends IpcService {
return false
}
@IpcMethod()
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath)
if (!stats.isFile()) {
return false
}
const resolvedPath = path.resolve(normalizedPath)
await this.copyFileToClipboardByPlatform(resolvedPath)
return true
} catch (error) {
console.error('Failed to copy file to clipboard:', error)
return false
}
}
private sanitizePath(target: string): string {
return target.trim().replace(/^['"]|['"]$/g, '')
}
private async copyFileToClipboardByPlatform(resolvedPath: string): Promise<void> {
switch (process.platform) {
case 'win32':
await this.copyFileToClipboardWindows(resolvedPath)
return
case 'darwin':
await this.copyFileToClipboardMac(resolvedPath)
return
default:
await this.copyFileToClipboardLinux(resolvedPath)
}
}
private async findLikelyFile(directory: string, expectedPath: string): Promise<string | null> {
try {
const dirStats = await fs.stat(directory)
@@ -171,6 +214,80 @@ class FileSystemService extends IpcService {
return false
}
}
private async copyFileToClipboardWindows(resolvedPath: string): Promise<void> {
const escaped = resolvedPath.replace(/'/g, "''")
try {
await execFileAsync('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-Command',
`Set-Clipboard -Path '${escaped}'`
])
return
} catch (error) {
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
}
const winPath = resolvedPath.replace(/\//g, '\\')
const fileList = `${winPath}\u0000\u0000`
const encodedList = Buffer.from(fileList, 'ucs2')
const dropFilesStructSize = 20
const buffer = Buffer.alloc(dropFilesStructSize + encodedList.length)
buffer.writeUInt32LE(dropFilesStructSize, 0)
buffer.writeInt32LE(0, 4)
buffer.writeInt32LE(0, 8)
buffer.writeUInt32LE(0, 12)
buffer.writeUInt32LE(1, 16)
encodedList.copy(buffer, dropFilesStructSize)
clipboard.writeBuffer('CF_HDROP', buffer)
clipboard.writeBuffer('Preferred DropEffect', Buffer.from([1, 0, 0, 0]))
clipboard.writeBuffer('FileNameW', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ucs2'))
clipboard.writeBuffer('FileName', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ascii'))
}
private async copyFileToClipboardMac(resolvedPath: string): Promise<void> {
const escaped = resolvedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
try {
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
return
} catch (error) {
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
}
const entries = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
'<array>',
` <string>${this.escapeForPlist(resolvedPath)}</string>`,
'</array>',
'</plist>'
]
const plist = Buffer.from(entries.join('\n'), 'utf8')
clipboard.writeBuffer('NSFilenamesPboardType', plist)
const fileUrl = pathToFileURL(resolvedPath).toString()
clipboard.writeBuffer('public.file-url', Buffer.from(`${fileUrl}\n`, 'utf8'))
}
private async copyFileToClipboardLinux(resolvedPath: string): Promise<void> {
const fileUrl = pathToFileURL(resolvedPath).toString()
const content = `copy\n${fileUrl}`
clipboard.writeBuffer('x-special/gnome-copied-files', Buffer.from(content, 'utf8'))
clipboard.writeBuffer('text/uri-list', Buffer.from(`${fileUrl}\n`, 'utf8'))
}
private escapeForPlist(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
}
export { FileSystemService }

25
src/main/utils/logger.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Main process logger utility
* Directly use electron-log/main
*/
import log from 'electron-log/main'
// Export electron-log instance
export default log
// Export commonly used logging methods
export const logger = log
// Predefined scoped loggers
export const scopedLoggers = {
main: log.scope('main'),
ipc: log.scope('ipc'),
window: log.scope('window'),
download: log.scope('download'),
engine: log.scope('engine'),
system: log.scope('system'),
storage: log.scope('storage'),
thumbnail: log.scope('thumbnail'),
history: log.scope('history')
}

View File

@@ -49,7 +49,7 @@ function AppContent() {
</ScrollArea>
</main>
<Toaster />
<Toaster richColors={true} />
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { useSetAtom } from 'jotai'
import {
AlertCircle,
CheckCircle2,
Copy,
ExternalLink,
FolderOpen,
Loader2,
@@ -110,6 +111,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
await handleOpenFileLocation()
}
const handleCopyToClipboard = async () => {
if (!download.outputPath) {
toast.error(t('notifications.copyFailed'))
return
}
try {
const success = await ipcServices.fs.copyFileToClipboard(download.outputPath)
if (!success) {
toast.error(t('notifications.copyFailed'))
return
}
toast.success(t('notifications.videoCopied'))
} catch (error) {
console.error('Failed to copy file to clipboard:', error)
toast.error(t('notifications.copyFailed'))
}
}
const handleRemoveHistory = async () => {
if (!isHistory) return
try {
@@ -166,11 +186,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
<div className="group relative w-full max-w-full overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
{/* Thumbnail */}
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60">
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
<ImageWithPlaceholder
src={thumbnailSrc}
alt={download.title}
className="w-32 h-18 object-cover aspect-video"
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6" />}
/>
</div>
@@ -179,18 +199,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs wrap-break-word">{download.title}</p>
</TooltipContent>
</Tooltip>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
{download.type}
@@ -285,13 +298,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
onClick={handleCopyToClipboard}
>
<ExternalLink className="h-4 w-4" />
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
<p>{t('history.copyToClipboard')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -346,6 +359,21 @@ export function DownloadItem({ download }: DownloadItemProps) {
<p>{t('history.openFile')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.copyToClipboard')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -44,7 +44,7 @@ export function ImageWithPlaceholder({
}
return (
<div className={cn('relative', className)}>
<div className={cn('relative w-full h-full', className)}>
{isLoading && (
<div
className={cn(

View File

@@ -48,6 +48,12 @@
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
"shareTitle": "Spread the word",
"shareDescription": "Share VidBee with your community in one click.",
"followAuthorActions": {
"follow": "Follow @nexmoex"
},
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
"followAuthorTitle": "Follow the Developer",
"shareActions": {
"twitter": "Share on X (Twitter)",
"facebook": "Share on Facebook",
@@ -175,6 +181,7 @@
"noHistory": "No download history yet",
"noHistoryDescription": "Your completed downloads will appear here",
"openFile": "Open File",
"copyToClipboard": "Copy to clipboard",
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openDownloadFolder": "Open Download Folder",
@@ -203,6 +210,7 @@
},
"notifications": {
"copyFailed": "Failed to copy to clipboard",
"videoCopied": "Video copied to clipboard",
"downloadCompleted": "Download completed",
"downloadFailed": "Download failed",
"downloadStarted": "Download started",

View File

@@ -43,6 +43,12 @@
"website": "官方网站",
"websiteDescription": "产品亮点、路线图与社区动态。"
},
"followAuthorActions": {
"follow": "关注 @nexmoex"
},
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
"followAuthorTitle": "关注开发者",
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源",
"sourceCode": "源代码已开放",

View File

@@ -213,6 +213,29 @@ export function About() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.followAuthorTitle')}</CardTitle>
<CardDescription>{t('about.followAuthorDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">
{t('about.followAuthorSupport')}
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openShareUrl('https://x.com/nexmoex')}
className="gap-2"
>
<Twitter className="h-4 w-4" />
{t('about.followAuthorActions.follow')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>