Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
612800bef2 | ||
|
|
41b58a6f70 | ||
|
|
1a8e26a847 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -107,11 +107,6 @@ function setupDownloadEvents(): void {
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
log.info('Skipping auto-updater initialization in development mode')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Initializing auto-updater...')
|
||||
|
||||
@@ -123,6 +118,12 @@ function initAutoUpdater(): void {
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info('Update available:', info.version)
|
||||
mainWindow?.webContents.send('update:available', info)
|
||||
|
||||
// If auto-update is enabled, the update will be downloaded automatically
|
||||
// because autoDownload is set to true
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, update will be downloaded automatically')
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
@@ -153,12 +154,18 @@ function initAutoUpdater(): void {
|
||||
}
|
||||
})
|
||||
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, checking for updates...')
|
||||
void autoUpdater.checkForUpdatesAndNotify()
|
||||
}
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
|
||||
// Check for updates immediately if auto-update is enabled
|
||||
const autoUpdateEnabled = settingsManager.get('autoUpdate')
|
||||
if (autoUpdateEnabled) {
|
||||
log.info('Auto-update is enabled, checking for updates immediately...')
|
||||
// Use checkForUpdates instead of checkForUpdatesAndNotify
|
||||
// because we have our own notification system and want to ensure immediate download
|
||||
void autoUpdater.checkForUpdates()
|
||||
} else {
|
||||
log.info('Auto-update is disabled, skipping automatic update check')
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize auto-updater:', error)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ class DownloadEngine extends EventEmitter {
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
@@ -92,6 +97,27 @@ class DownloadEngine extends EventEmitter {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const info = JSON.parse(stdout)
|
||||
|
||||
// Calculate estimated file size for formats missing filesize information
|
||||
// Using tbr (total bitrate in kbps) and duration (in seconds)
|
||||
// Formula: (tbr * 1000) / 8 * duration = size in bytes
|
||||
if (info.formats && Array.isArray(info.formats) && info.duration) {
|
||||
const duration = info.duration
|
||||
for (const format of info.formats) {
|
||||
if (
|
||||
!format.filesize &&
|
||||
!format.filesize_approx &&
|
||||
format.tbr &&
|
||||
typeof format.tbr === 'number' &&
|
||||
duration > 0
|
||||
) {
|
||||
// Calculate estimated size: tbr (kbps) * 1000 / 8 bits per byte * duration (seconds)
|
||||
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
|
||||
format.filesize_approx = estimatedSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopedLoggers.download.info('Successfully retrieved video info for:', url)
|
||||
resolve(info)
|
||||
} catch (error) {
|
||||
|
||||
@@ -61,43 +61,30 @@ function AppContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const startUpdateDownload = async () => {
|
||||
if (updateDownloadInProgressRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
updateDownloadInProgressRef.current = true
|
||||
toast.info(t('about.notifications.downloadStarted'))
|
||||
|
||||
try {
|
||||
const result = await ipcServices.update.downloadUpdate()
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? 'Unknown error')
|
||||
}
|
||||
} catch (error) {
|
||||
updateDownloadInProgressRef.current = false
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('about.notifications.unknownErrorFallback')
|
||||
toast.error(t('about.notifications.updateError', { error: message }))
|
||||
const handleGoToDownloadPage = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open('https://vidbee.org/download/', '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||
const info = (rawInfo ?? {}) as { version?: string }
|
||||
const versionLabel = info.version ?? ''
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||
|
||||
if (autoUpdateEnabled) {
|
||||
void startUpdateDownload()
|
||||
} else {
|
||||
toast(t('about.notifications.downloadUpdate', { version: versionLabel }), {
|
||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
|
||||
action: {
|
||||
label: t('about.notifications.manualDownloadAction'),
|
||||
onClick: () => {
|
||||
void startUpdateDownload()
|
||||
}
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownloadPage
|
||||
}
|
||||
})
|
||||
// No need to manually call downloadUpdate() because autoDownload is true
|
||||
} else {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownloadPage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,44 +41,46 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('audioExtract.title')}</CardTitle>
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audioExtract.selectFormat')}</Label>
|
||||
<Select value={extractFormat} onValueChange={setExtractFormat}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
|
||||
<Select value={extractFormat} onValueChange={setExtractFormat}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('audioExtract.selectQuality')}</Label>
|
||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onExtract('extract')} className="w-full">
|
||||
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
|
||||
{t('audioExtract.extract')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -73,9 +73,22 @@ export function FormatSelector({
|
||||
|
||||
useEffect(() => {
|
||||
// Filter and sort formats
|
||||
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
|
||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||
const videos = formats.filter(
|
||||
(f) =>
|
||||
f.video_ext !== 'none' &&
|
||||
f.vcodec &&
|
||||
f.vcodec !== 'none' &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
)
|
||||
const audios = formats.filter(
|
||||
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
|
||||
(f) =>
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext) &&
|
||||
f.protocol !== 'm3u8' &&
|
||||
f.protocol !== 'm3u8_native'
|
||||
)
|
||||
|
||||
// Apply showMoreFormats filter
|
||||
@@ -87,6 +100,48 @@ export function FormatSelector({
|
||||
? audios
|
||||
: audios.filter((f) => f.ext !== 'webm')
|
||||
|
||||
// Sort formats by quality (best first)
|
||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by height (higher is better)
|
||||
const aHeight = a.height ?? 0
|
||||
const bHeight = b.height ?? 0
|
||||
if (aHeight !== bHeight) {
|
||||
return bHeight - aHeight
|
||||
}
|
||||
// If same height, sort by fps (higher is better)
|
||||
const aFps = a.fps ?? 0
|
||||
const bFps = b.fps ?? 0
|
||||
if (aFps !== bFps) {
|
||||
return bFps - aFps
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by bitrate/quality if available
|
||||
const aQuality = a.tbr ?? a.quality ?? 0
|
||||
const bQuality = b.tbr ?? b.quality ?? 0
|
||||
if (aQuality !== bQuality) {
|
||||
return bQuality - aQuality
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
filteredVideos.sort(sortVideoFormatsByQuality)
|
||||
filteredAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(filteredVideos)
|
||||
setAudioFormats(filteredAudios)
|
||||
|
||||
@@ -121,25 +176,50 @@ export function FormatSelector({
|
||||
}
|
||||
|
||||
const formatVideoLabel = (format: VideoFormat) => {
|
||||
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
|
||||
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
|
||||
const parts: string[] = []
|
||||
// Resolution
|
||||
if (format.height) {
|
||||
parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`)
|
||||
}
|
||||
// Format extension
|
||||
parts.push(format.ext.toUpperCase())
|
||||
// Codec (if showMoreFormats is enabled)
|
||||
if (settings.showMoreFormats && format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0])
|
||||
}
|
||||
// Audio indicator
|
||||
if (format.acodec !== 'none') {
|
||||
parts.push('🔊')
|
||||
}
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
|
||||
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatAudioLabel = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Quality
|
||||
const quality = format.format_note || t('download.unknownQuality')
|
||||
parts.push(quality)
|
||||
// Format extension
|
||||
const ext = format.ext === 'webm' ? 'opus' : format.ext
|
||||
parts.push(ext.toUpperCase())
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
return `${quality} | ${ext} | ${size}`
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectVideoFormat')}</Label>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
||||
<Select
|
||||
value={selectedVideo}
|
||||
onValueChange={(value) => {
|
||||
@@ -147,25 +227,25 @@ export function FormatSelector({
|
||||
onVideoFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
{videoFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatVideoLabel(format)}
|
||||
<span className="text-sm">{formatVideoLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectAudioFormat')}</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
@@ -173,18 +253,20 @@ export function FormatSelector({
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
<SelectItem value="none" className="cursor-pointer py-2.5">
|
||||
<span className="text-sm">{t('download.noAudio')}</span>
|
||||
</SelectItem>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatAudioLabel(format)}
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -196,8 +278,8 @@ export function FormatSelector({
|
||||
|
||||
// Audio only
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('download.selectFormat')}</Label>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
@@ -205,17 +287,17 @@ export function FormatSelector({
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-11">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="max-h-[300px] p-1.5">
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="font-mono text-xs"
|
||||
className="cursor-pointer py-2.5"
|
||||
>
|
||||
{formatAudioLabel(format)}
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -115,53 +115,59 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
|
||||
<div className="space-y-5">
|
||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('download.back')}
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full md:w-80 rounded-lg aspect-video"
|
||||
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
|
||||
fallbackIcon={<Play className="h-12 w-12" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-3">
|
||||
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2 items-center">
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDuration(videoInfo.duration)}
|
||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
{formatViews(videoInfo.view_count)}
|
||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.uploader && (
|
||||
<Badge variant="outline" className="px-2.5 py-1">
|
||||
{videoInfo.uploader}
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={titleId}>{t('download.title')}</Label>
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor={titleId} className="text-sm font-semibold">
|
||||
{t('download.title')}
|
||||
</Label>
|
||||
<Input
|
||||
id={titleId}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="font-medium"
|
||||
className="font-medium h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,14 +176,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
|
||||
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="video" className="text-sm font-medium">
|
||||
{t('download.video')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="text-sm font-medium">
|
||||
{t('download.audio')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="video" className="space-y-4 mt-4">
|
||||
<TabsContent value="video" className="space-y-5 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="video"
|
||||
@@ -194,13 +204,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
/>
|
||||
|
||||
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
|
||||
<Button
|
||||
onClick={() => handleDownload('video')}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadVideo')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audio" className="space-y-4 mt-4">
|
||||
<TabsContent value="audio" className="space-y-5 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="audio"
|
||||
@@ -218,7 +233,12 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
/>
|
||||
|
||||
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
|
||||
<Button
|
||||
onClick={() => handleDownload('audio')}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Check updates",
|
||||
"download": "Download",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"goToDownload": "Go to download page",
|
||||
"openRepo": "Open GitHub repository",
|
||||
"view": "View",
|
||||
"visit": "Visit"
|
||||
@@ -32,6 +34,7 @@
|
||||
"restartToUpdate": "Restart now to install update?",
|
||||
"restartNowAction": "Restart now",
|
||||
"updateAvailable": "Update available: {{version}}",
|
||||
"updateAvailableMessage": "A new version {{version}} is available. Please download it from the official website.",
|
||||
"updateDownloaded": "Update downloaded, restart to install",
|
||||
"updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
|
||||
"updateError": "Failed to check for updates: {{error}}",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Download,
|
||||
Facebook,
|
||||
FileText,
|
||||
Github,
|
||||
@@ -77,6 +78,47 @@ export function About() {
|
||||
) => {
|
||||
await saveSetting({ key, value })
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
|
||||
// If auto-update is enabled, check for updates immediately
|
||||
if (key === 'autoUpdate' && value === true) {
|
||||
try {
|
||||
toast.info(t('about.notifications.checkingUpdates'))
|
||||
const result = await ipcServices.update.checkForUpdates()
|
||||
|
||||
if (result.available) {
|
||||
// The update will be downloaded automatically because autoDownload is enabled
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownload
|
||||
}
|
||||
})
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
})
|
||||
} else if (result.error) {
|
||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||
setLatestVersionState({
|
||||
status: 'error',
|
||||
error: result.error
|
||||
})
|
||||
} else {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error)
|
||||
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoToDownload = () => {
|
||||
openShareUrl('https://vidbee.org/download/')
|
||||
}
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
@@ -85,7 +127,12 @@ export function About() {
|
||||
const result = await ipcServices.update.checkForUpdates()
|
||||
|
||||
if (result.available) {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
|
||||
action: {
|
||||
label: t('about.actions.goToDownload'),
|
||||
onClick: handleGoToDownload
|
||||
}
|
||||
})
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
@@ -247,6 +294,12 @@ export function About() {
|
||||
<Github className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{latestVersionState?.status === 'available' ? (
|
||||
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('about.actions.goToDownload')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleCheckForUpdates} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('about.actions.checkUpdates')}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface VideoFormat {
|
||||
audio_ext?: string
|
||||
tbr?: number
|
||||
quality?: number
|
||||
protocol?: string // http, https, m3u8, m3u8_native, etc.
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
|
||||
Reference in New Issue
Block a user