Files
VidBee/src/renderer/src/components/video/FormatSelector.tsx

307 lines
9.2 KiB
TypeScript

import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { useAtom } from 'jotai'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
import { settingsAtom } from '../../store/settings'
interface FormatSelectorProps {
formats: VideoFormat[]
type: 'video' | 'audio'
onVideoFormatChange?: (format: string) => void
onAudioFormatChange?: (format: string) => void
}
export function FormatSelector({
formats,
type,
onVideoFormatChange,
onAudioFormatChange
}: FormatSelectorProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
const [selectedVideo, setSelectedVideo] = useState('')
const [selectedAudio, setSelectedAudio] = useState('')
const pickVideoFormatForPreset = useCallback(
(formats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
if (formats.length === 0) {
return null
}
const heightLimit = qualityPresetToVideoHeight[preset]
const byHeightDescending = (a: VideoFormat, b: VideoFormat) =>
(b.height ?? 0) - (a.height ?? 0)
const sorted = [...formats].sort(byHeightDescending)
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
if (!heightLimit) {
return sorted[0]
}
const matchingLimit = sorted.find((format) => {
if (!format.height) return false
return format.height <= heightLimit
})
return matchingLimit ?? sorted[0]
},
[]
)
useEffect(() => {
// Filter and sort formats
// 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.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
// Apply showMoreFormats filter
const filteredVideos = settings.showMoreFormats
? videos
: videos.filter((f) => f.ext !== 'webm' && !f.vcodec?.startsWith('vp'))
const filteredAudios = settings.showMoreFormats
? 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)
// Auto-select best format based on preferences
if (filteredVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
if (preferred) {
setSelectedVideo(preferred.format_id)
onVideoFormatChange?.(preferred.format_id)
}
}
if (filteredAudios.length > 0 && !selectedAudio) {
const best = filteredAudios[0]
setSelectedAudio(best.format_id)
onAudioFormatChange?.(best.format_id)
}
}, [
formats,
settings,
selectedVideo,
selectedAudio,
onAudioFormatChange,
onVideoFormatChange,
pickVideoFormatForPreset
])
const formatSize = (bytes?: number) => {
if (!bytes) return t('download.unknownSize')
const mb = bytes / 1000000
return `${mb.toFixed(2)} MB`
}
const formatVideoLabel = (format: VideoFormat) => {
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)
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)
if (size !== t('download.unknownSize')) {
parts.push(size)
}
return parts.join(' • ')
}
if (type === 'video') {
return (
<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) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<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="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
// Audio only
return (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}