Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877f088136 | ||
|
|
5ee08a3e62 | ||
|
|
137a07cf4e | ||
|
|
21a5adbdf2 | ||
|
|
81f52f86e5 | ||
|
|
c8147cafe3 | ||
|
|
7074f698e5 | ||
|
|
b254bb6dba | ||
|
|
1caca0ea9a | ||
|
|
12ec142a77 |
54
.github/workflows/build.yml
vendored
54
.github/workflows/build.yml
vendored
@@ -8,6 +8,17 @@ on:
|
||||
type: boolean
|
||||
default: false
|
||||
description: 'Whether to upload build artifacts'
|
||||
secrets:
|
||||
MAC_CERT_P12_BASE64:
|
||||
required: false
|
||||
MAC_CERT_P12_PASSWORD:
|
||||
required: false
|
||||
APPLE_API_KEY_ID:
|
||||
required: false
|
||||
APPLE_API_ISSUER:
|
||||
required: false
|
||||
APPLE_API_KEY_P8_BASE64:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -68,6 +79,8 @@ jobs:
|
||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||
Copy-Item -Path $source -Destination $destination -Force
|
||||
Remove-Item ffmpeg.zip -Force
|
||||
Remove-Item ffmpeg -Recurse -Force
|
||||
|
||||
- name: Download ffmpeg binary (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -112,6 +125,7 @@ jobs:
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||
rm -rf ffmpeg.tar.xz ffmpeg
|
||||
|
||||
- name: Download yt-dlp binary
|
||||
shell: bash
|
||||
@@ -124,6 +138,46 @@ jobs:
|
||||
- name: Lint and format check
|
||||
run: pnpm run check && pnpm run typecheck
|
||||
|
||||
- name: Setup macOS signing
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
|
||||
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
|
||||
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Check if all required secrets are present
|
||||
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
|
||||
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
|
||||
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
|
||||
echo "::notice::macOS signing secrets not available, skipping code signing setup"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
|
||||
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
|
||||
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
|
||||
|
||||
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
|
||||
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
|
||||
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
|
||||
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build application
|
||||
run: ${{ matrix.build_script }}
|
||||
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
secrets: inherit
|
||||
|
||||
release:
|
||||
needs: [build]
|
||||
@@ -42,3 +43,9 @@ jobs:
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Notify Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
|
||||
run: |
|
||||
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"
|
||||
|
||||
@@ -8,11 +8,16 @@ protocols:
|
||||
- vidbee
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!**/.context/**'
|
||||
- '!**/.github/**'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!extension/**'
|
||||
- '!monkey/**'
|
||||
- '!screenshots/**'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
@@ -23,9 +28,10 @@ nsis:
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
identity: null
|
||||
hardenedRuntime: true
|
||||
entitlements: build/entitlements.mac.plist
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
notarize: true
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
target:
|
||||
- target: zip
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.8",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const { execSync } = require('node:child_process')
|
||||
const { execSync, spawnSync } = require('node:child_process')
|
||||
const https = require('node:https')
|
||||
const http = require('node:http')
|
||||
|
||||
@@ -33,7 +33,7 @@ const PLATFORM_CONFIG = {
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /win64.*gpl.*\.zip$/i,
|
||||
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
||||
binaryName: 'ffmpeg.exe'
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ const PLATFORM_CONFIG = {
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
||||
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
@@ -125,24 +125,43 @@ function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const file = fs.createWriteStream(dest)
|
||||
let downloadedBytes = 0
|
||||
|
||||
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||
const redirectUrl = response.headers.location
|
||||
if (!redirectUrl) {
|
||||
return reject(new Error(`Redirect without location for ${url}`))
|
||||
}
|
||||
log(`Redirected to ${redirectUrl}`, 'info')
|
||||
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
const contentLength = response.headers['content-length']
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||
return reject(
|
||||
new Error(
|
||||
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
})
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
log(
|
||||
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
|
||||
downloadedBytes ? 'success' : 'warn'
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -154,6 +173,7 @@ function downloadFile(url, dest) {
|
||||
request.on('error', (err) => {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
log(`Download error for ${url}: ${err.message}`, 'error')
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -163,6 +183,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||
let lastError
|
||||
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
|
||||
await downloadFile(url, dest)
|
||||
return
|
||||
} catch (error) {
|
||||
@@ -170,7 +191,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||
safeUnlink(dest)
|
||||
if (attempt < retries) {
|
||||
const backoff = delayMs * attempt
|
||||
log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
@@ -298,6 +319,37 @@ function fileExists(filePath) {
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return 'unknown size'
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${Math.round(bytes / (1024 * 1024))} MB`
|
||||
}
|
||||
return `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
function checkBinary(filePath, args, label) {
|
||||
const result = spawnSync(filePath, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 8000,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { ok: false, message: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||
return { ok: false, message: output || `exit code ${result.status}` }
|
||||
}
|
||||
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
|
||||
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
|
||||
}
|
||||
|
||||
function getDenoAssetName(platform, arch) {
|
||||
if (platform === 'win32') {
|
||||
if (arch === 'arm64') {
|
||||
@@ -330,6 +382,10 @@ async function downloadYtDlp(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -342,6 +398,11 @@ async function downloadYtDlp(config) {
|
||||
await downloadFileWithRetry(url, tempPath)
|
||||
fs.renameSync(tempPath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
@@ -356,6 +417,10 @@ async function downloadFfmpegWindows(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -392,6 +457,11 @@ async function downloadFfmpegWindows(config) {
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -416,6 +486,10 @@ async function downloadFfmpegMac(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -448,6 +522,11 @@ async function downloadFfmpegMac(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -465,6 +544,10 @@ async function downloadFfmpegLinux(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -502,6 +585,11 @@ async function downloadFfmpegLinux(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -528,6 +616,10 @@ async function downloadDenoRuntime() {
|
||||
const outputPath = path.join(RESOURCES_DIR, outputName)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${outputName} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -549,6 +641,11 @@ async function downloadDenoRuntime() {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${outputName} successfully`, 'success')
|
||||
|
||||
fs.unlinkSync(tempZip)
|
||||
|
||||
@@ -68,7 +68,7 @@ export const buildDownloadArgs = (
|
||||
settings: AppSettings,
|
||||
jsRuntimeArgs: string[] = []
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
const args: string[] = ['--no-playlist', '--no-mtime']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
@@ -95,11 +95,26 @@ export const buildDownloadArgs = (
|
||||
args.push('--download-sections', `*${start}-${end || ''}`)
|
||||
}
|
||||
|
||||
const embedSubs = settings.embedSubs
|
||||
const embedMetadata = settings.embedMetadata
|
||||
const embedChapters = settings.embedChapters
|
||||
|
||||
// Subtitles
|
||||
if (options.downloadSubs) {
|
||||
args.push('--write-subs', '--sub-langs', 'all')
|
||||
if (options.downloadSubs || embedSubs) {
|
||||
args.push('--sub-langs', 'all')
|
||||
}
|
||||
|
||||
if (options.downloadSubs) {
|
||||
args.push('--write-subs')
|
||||
}
|
||||
|
||||
args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')
|
||||
if (process.platform !== 'darwin') {
|
||||
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
||||
}
|
||||
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
|
||||
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||
const filenameTemplate = sanitizeFilenameTemplate(
|
||||
|
||||
@@ -364,12 +364,6 @@ function initAutoUpdater(): void {
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log.info('Update downloaded:', info.version)
|
||||
mainWindow?.webContents.send('update:downloaded', info)
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('update:show-notification', {
|
||||
version: info.version
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
|
||||
@@ -55,7 +55,7 @@ function AppContent() {
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const setUpdateReady = useSetAtom(updateReadyAtom)
|
||||
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||
const { t } = useTranslation()
|
||||
const { i18n } = useTranslation()
|
||||
const updateDownloadInProgressRef = useRef(false)
|
||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||
const navigate = useNavigate()
|
||||
@@ -197,14 +197,27 @@ function AppContent() {
|
||||
available: true,
|
||||
version: info.version
|
||||
})
|
||||
|
||||
const versionLabel = info?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? i18n.t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: i18n.t('about.notifications.updateDownloaded')
|
||||
toast.info(downloadedMessage, {
|
||||
action: {
|
||||
label: i18n.t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateError = (rawMessage: unknown) => {
|
||||
const message = typeof rawMessage === 'string' ? rawMessage : ''
|
||||
resetDownloadState()
|
||||
|
||||
const errorMessage = message || t('about.notifications.unknownErrorFallback')
|
||||
toast.error(t('about.notifications.updateError', { error: errorMessage }))
|
||||
const errorMessage = message || i18n.t('about.notifications.unknownErrorFallback')
|
||||
toast.error(i18n.t('about.notifications.updateError', { error: errorMessage }))
|
||||
}
|
||||
|
||||
const handleDownloadProgress = (rawProgress: unknown) => {
|
||||
@@ -214,39 +227,20 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateNotification = (rawPayload: unknown) => {
|
||||
const payload = (rawPayload ?? {}) as { version?: string }
|
||||
const versionLabel = payload?.version ?? ''
|
||||
const downloadedMessage = versionLabel
|
||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||
: t('about.notifications.updateDownloaded')
|
||||
|
||||
toast.info(downloadedMessage, {
|
||||
action: {
|
||||
label: t('about.notifications.restartNowAction'),
|
||||
onClick: () => {
|
||||
void ipcServices.update.quitAndInstall()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only listen to update events that should be shown globally
|
||||
// update:available shows a visual indicator in the sidebar
|
||||
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.on('update:error', handleUpdateError)
|
||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [setUpdateAvailable, setUpdateReady, t])
|
||||
}, [i18n, setUpdateAvailable, setUpdateReady])
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
|
||||
@@ -400,6 +400,10 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
|
||||
const statusIcon = getStatusIcon()
|
||||
const statusText = getStatusText()
|
||||
const progressInfo = download.progress
|
||||
const showInlineProgress = Boolean(
|
||||
progressInfo && download.status !== 'completed' && download.status !== 'error'
|
||||
)
|
||||
const sourceDisplay =
|
||||
download.uploader && download.channel && download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
@@ -725,6 +729,24 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showInlineProgress && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="font-medium shrink-0">
|
||||
{(progressInfo?.percent ?? 0).toFixed(1)}%
|
||||
</span>
|
||||
{progressInfo?.downloaded && progressInfo?.total && (
|
||||
<span className="truncate max-w-[120px]">
|
||||
{progressInfo.downloaded} / {progressInfo.total}
|
||||
</span>
|
||||
)}
|
||||
{progressInfo?.currentSpeed && (
|
||||
<span className="truncate max-w-[80px]">{progressInfo.currentSpeed}</span>
|
||||
)}
|
||||
{progressInfo?.eta && (
|
||||
<span className="truncate max-w-[80px]">ETA: {progressInfo.eta}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Timestamp */}
|
||||
{timestamp && (
|
||||
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
|
||||
@@ -898,26 +920,8 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
|
||||
{/* Progress */}
|
||||
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
||||
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
|
||||
<div className="bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
|
||||
<span className="font-medium shrink-0">
|
||||
{download.progress.percent.toFixed(1)}%
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
|
||||
{download.progress.downloaded && download.progress.total && (
|
||||
<span className="truncate max-w-[100px]">
|
||||
{download.progress.downloaded} / {download.progress.total}
|
||||
</span>
|
||||
)}
|
||||
{download.progress.currentSpeed && (
|
||||
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
|
||||
)}
|
||||
{download.progress.eta && (
|
||||
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"currentLocation": "Current download location - ",
|
||||
"downloadLocation": "Download location",
|
||||
"downloadSubs": "Download subtitles if available",
|
||||
"downloadSubsHint": "Save subtitles as separate files when available",
|
||||
"end": "End",
|
||||
"endHint": "If kept empty, it will be downloaded to the end",
|
||||
"endPlaceholder": "10:00",
|
||||
@@ -420,6 +421,14 @@
|
||||
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
|
||||
"enableAnalytics": "Help improve VidBee",
|
||||
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||
"embedChapters": "Embed chapters",
|
||||
"embedChaptersDescription": "Add chapter markers to the file when available",
|
||||
"embedMetadata": "Embed metadata",
|
||||
"embedMetadataDescription": "Write title, artist, and other metadata when available",
|
||||
"embedSubs": "Embed subtitles",
|
||||
"embedSubsDescription": "Embed subtitles into the video file (mp4, webm, mkv)",
|
||||
"embedThumbnail": "Embed thumbnail",
|
||||
"embedThumbnailDescription": "Add the thumbnail as cover art",
|
||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
|
||||
@@ -39,9 +39,10 @@ type LatestVersionState =
|
||||
| null
|
||||
|
||||
export function About() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const [updateReady] = useAtom(updateReadyAtom)
|
||||
const [updateAvailableState] = useAtom(updateAvailableAtom)
|
||||
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
@@ -70,6 +71,17 @@ export function About() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!updateAvailableState.available) {
|
||||
return
|
||||
}
|
||||
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: updateAvailableState.version ?? ''
|
||||
})
|
||||
}, [updateAvailableState.available, updateAvailableState.version])
|
||||
|
||||
// Listen for update events only in About page
|
||||
useEffect(() => {
|
||||
if (!window?.api) {
|
||||
@@ -81,7 +93,7 @@ export function About() {
|
||||
const versionLabel = info.version ?? ''
|
||||
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
toast.success(i18n.t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: versionLabel
|
||||
@@ -115,7 +127,7 @@ export function About() {
|
||||
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||
}
|
||||
}, [setUpdateAvailable, t])
|
||||
}, [i18n, setUpdateAvailable])
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
@@ -264,6 +276,8 @@ export function About() {
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
||||
const shouldShowCheckUpdates =
|
||||
!updateAvailableState.available && latestVersionState?.status !== 'available'
|
||||
|
||||
const handleXFeedback = useCallback(() => {
|
||||
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
|
||||
@@ -379,10 +393,12 @@ export function About() {
|
||||
{t('about.actions.goToDownload')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t('about.actions.checkUpdates')}
|
||||
</Button>
|
||||
{shouldShowCheckUpdates ? (
|
||||
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t('about.actions.checkUpdates')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||
|
||||
@@ -519,6 +519,94 @@ export function Settings() {
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedSubs')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedSubsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedSubs ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedSubs', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedSubs:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
{platform !== 'darwin' && (
|
||||
<>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedMetadata')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedMetadataDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedMetadata ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedMetadata', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedMetadata:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedChapters')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedChaptersDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedChapters ?? true}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedChapters', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedChapters:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
|
||||
@@ -271,6 +271,10 @@ export interface AppSettings {
|
||||
autoUpdate: boolean
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
enableAnalytics: boolean
|
||||
embedSubs: boolean
|
||||
embedThumbnail: boolean
|
||||
embedMetadata: boolean
|
||||
embedChapters: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
|
||||
@@ -294,5 +298,9 @@ export const defaultSettings: AppSettings = {
|
||||
launchAtLogin: false,
|
||||
autoUpdate: true,
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
enableAnalytics: true
|
||||
enableAnalytics: true,
|
||||
embedSubs: true,
|
||||
embedThumbnail: true,
|
||||
embedMetadata: true,
|
||||
embedChapters: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user