diff --git a/src/components/custom/playlistSelectionGroup.tsx b/src/components/custom/playlistSelectionGroup.tsx index 65278ec..f34efe0 100644 --- a/src/components/custom/playlistSelectionGroup.tsx +++ b/src/components/custom/playlistSelectionGroup.tsx @@ -35,8 +35,8 @@ const PlaylistSelectionGroupItem = React.forwardRef<
-
- + -
- -
+ +

{video.title}

-

{video.channel || video.uploader || 'unknown'}

+

{video.creator || video.channel || video.uploader || 'unknown'}

@@ -79,4 +79,4 @@ const PlaylistSelectionGroupItem = React.forwardRef< }) PlaylistSelectionGroupItem.displayName = "PlaylistSelectionGroupItem" -export { PlaylistSelectionGroup, PlaylistSelectionGroupItem } \ No newline at end of file +export { PlaylistSelectionGroup, PlaylistSelectionGroupItem } diff --git a/src/components/pages/downloader/bottomBar.tsx b/src/components/pages/downloader/bottomBar.tsx new file mode 100644 index 0000000..f3567ec --- /dev/null +++ b/src/components/pages/downloader/bottomBar.tsx @@ -0,0 +1,443 @@ +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { useAppContext } from "@/providers/appContextProvider"; +import { useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store"; +import { formatBitrate, formatFileSize } from "@/utils"; +import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { RawVideoInfo, VideoFormat } from "@/types/video"; +// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +interface DownloadConfigDialogProps { + selectedFormatFileType: "video+audio" | "video" | "audio" | "unknown"; +} + +interface BottomBarProps { + videoMetadata: RawVideoInfo; + selectedFormat: VideoFormat | undefined; + selectedFormatFileType: "video+audio" | "video" | "audio" | "unknown"; + selectedVideoFormat: VideoFormat | undefined; + selectedAudioFormat: VideoFormat | undefined; + containerRef: React.RefObject; +} + +function DownloadConfigDialog({ selectedFormatFileType }: DownloadConfigDialogProps) { + const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab); + const activeDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.activeDownloadConfigurationTab); + const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat); + const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat); + const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat); + const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration); + const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab); + const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey); + + const embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata); + const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata); + const embedVideoThumbnail = useSettingsPageStatesStore(state => state.settings.embed_video_thumbnail); + const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands); + + return ( + + + + + + + + +

Configurations

+
+
+ + + Configurations + Tweak this download's configurations + +
+ setActiveDownloadConfigurationTab(tab)} + > + + Options + Commands + + + {useCustomCommands ? ( + + + Options Unavailable! + + You cannot use these options when custom commands are enabled. To use these options, disable custom commands from Settings. + + + ) : null} +
+ + {(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? ( + setDownloadConfigurationKey('output_format', value)} + disabled={useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : selectedFormatFileType && selectedFormatFileType === 'audio' ? ( + setDownloadConfigurationKey('output_format', value)} + disabled={useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : ( + setDownloadConfigurationKey('output_format', value)} + disabled={useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+
+ + setDownloadConfigurationKey('sponsorblock', value)} + disabled={useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ setDownloadConfigurationKey('embed_metadata', checked)} + disabled={useCustomCommands} + /> + +
+
+ setDownloadConfigurationKey('embed_thumbnail', checked)} + disabled={useCustomCommands} + /> + +
+
+
+ + {!useCustomCommands ? ( + + + Enable Custom Commands! + + To run custom commands for downloads, please enable it from the Settings. + + + ) : null} +
+ + {customCommands.length === 0 ? ( +

NO CUSTOM COMMAND TEMPLATE ADDED YET!

+ ) : ( + setDownloadConfigurationKey('custom_command', value)} + > + {customCommands.map((command) => ( +
+ + +
+ ))} +
+ )} +
+
+
+
+
+
+ ); +} + +export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileType, selectedVideoFormat, selectedAudioFormat, containerRef }: BottomBarProps) { + const { startDownload } = useAppContext(); + + const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab); + const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload); + const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat); + const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat); + const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat); + const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); + const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex); + const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration); + const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab); + const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload); + + const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format); + const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + const bottomBarRef = useRef(null); + + let selectedFormatExtensionMsg = 'Auto - unknown'; + if (activeDownloadModeTab === 'combine') { + if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') { + selectedFormatExtensionMsg = `Combined - ${downloadConfiguration.output_format.toUpperCase()}`; + } + else if (videoFormat !== 'auto') { + selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`; + } + else if (selectedAudioFormat?.ext && selectedVideoFormat?.ext) { + selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormat.ext.toUpperCase()}`; + } else { + selectedFormatExtensionMsg = `Combined - unknown`; + } + } else if (selectedFormat?.ext) { + if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || videoFormat !== 'auto')) { + selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : videoFormat.toUpperCase()}`; + } else if (selectedFormatFileType === 'audio' && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || audioFormat !== 'auto')) { + selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : audioFormat.toUpperCase()}`; + } else if (selectedFormatFileType === 'unknown' && downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') { + selectedFormatExtensionMsg = `Forced - ${downloadConfiguration.output_format.toUpperCase()}`; + } else { + selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`; + } + } + + let selectedFormatResolutionMsg = 'unknown'; + if (activeDownloadModeTab === 'combine') { + selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${selectedAudioFormat?.tbr ? formatBitrate(selectedAudioFormat.tbr) : 'unknown'}`; + } else if (selectedFormat?.resolution) { + selectedFormatResolutionMsg = selectedFormat.resolution; + } + + let selectedFormatDynamicRangeMsg = ''; + if (activeDownloadModeTab === 'combine') { + selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' ? selectedVideoFormat.dynamic_range : ''; + } else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR') { + selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range; + } + + let selectedFormatFileSizeMsg = 'unknown filesize'; + if (activeDownloadModeTab === 'combine') { + selectedFormatFileSizeMsg = selectedVideoFormat?.filesize_approx && selectedAudioFormat?.filesize_approx ? formatFileSize(selectedVideoFormat.filesize_approx + selectedAudioFormat.filesize_approx) : 'unknown filesize'; + } else if (selectedFormat?.filesize_approx) { + selectedFormatFileSizeMsg = formatFileSize(selectedFormat.filesize_approx); + } + + let selectedFormatFinalMsg = ''; + if (activeDownloadModeTab === 'combine') { + if (selectedCombinableVideoFormat && selectedCombinableAudioFormat) { + selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`; + } else { + selectedFormatFinalMsg = `Choose a video and audio stream to combine`; + } + } else { + if (selectedFormat) { + selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`; + } else { + selectedFormatFinalMsg = `Choose a stream to download`; + } + } + + useEffect(() => { + const updateBottomBarWidth = (): void => { + if (containerRef.current && bottomBarRef.current) { + bottomBarRef.current.style.width = `${containerRef.current.offsetWidth}px`; + const containerRect = containerRef.current.getBoundingClientRect(); + bottomBarRef.current.style.left = `${containerRect.left}px`; + } + }; + updateBottomBarWidth(); + const resizeObserver = new ResizeObserver(() => { + updateBottomBarWidth(); + }); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + window.addEventListener('resize', updateBottomBarWidth); + window.addEventListener('scroll', updateBottomBarWidth); + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateBottomBarWidth); + window.removeEventListener('scroll', updateBottomBarWidth); + }; + }, []); + + useEffect(() => { + useCustomCommands ? setActiveDownloadConfigurationTab('commands') : setActiveDownloadConfigurationTab('options'); + }, []); + + return ( +
+
+
+ {selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio') && ( +
+
+ {videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].title : 'Unknown' } + {selectedFormatFinalMsg} +
+
+
+ + +
+
+ ); +} diff --git a/src/components/pages/downloader/playlistDownloader.tsx b/src/components/pages/downloader/playlistDownloader.tsx new file mode 100644 index 0000000..cbfda09 --- /dev/null +++ b/src/components/pages/downloader/playlistDownloader.tsx @@ -0,0 +1,375 @@ +import { useDownloaderPageStatesStore } from "@/services/store"; +import { DownloadCloud, Info, ListVideo, AlertCircleIcon } from "lucide-react"; +import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { RawVideoInfo, VideoFormat } from "@/types/video"; +// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup"; +import { PlaylistSelectionGroup, PlaylistSelectionGroupItem } from "@/components/custom/playlistSelectionGroup"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; + +interface PlaylistPreviewSelectionProps { + videoMetadata: RawVideoInfo; +} + +interface SelectivePlaylistDownloadProps { + videoMetadata: RawVideoInfo; + audioOnlyFormats: VideoFormat[] | undefined; + videoOnlyFormats: VideoFormat[] | undefined; + combinedFormats: VideoFormat[] | undefined; + qualityPresetFormats: VideoFormat[] | undefined; + allFilteredFormats: VideoFormat[]; + subtitleLanguages: { code: string; lang: string }[]; + selectedFormat: VideoFormat | undefined; +} + +interface CombinedPlaylistDownloadProps { + audioOnlyFormats: VideoFormat[] | undefined; + videoOnlyFormats: VideoFormat[] | undefined; + subtitleLanguages: { code: string; lang: string }[]; + selectedFormat: VideoFormat | undefined; +} + +interface PlaylistDownloaderProps { + videoMetadata: RawVideoInfo; + audioOnlyFormats: VideoFormat[] | undefined; + videoOnlyFormats: VideoFormat[] | undefined; + combinedFormats: VideoFormat[] | undefined; + qualityPresetFormats: VideoFormat[] | undefined; + allFilteredFormats: VideoFormat[]; + subtitleLanguages: { code: string; lang: string }[]; + selectedFormat: VideoFormat | undefined; +} + +function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) { + const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex); + const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat); + const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat); + const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat); + const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); + const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex); + const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration); + + return ( +
+

+ + Playlist ({videoMetadata.entries[0].n_entries}) +

+
+

{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}

+

{videoMetadata.entries[0].playlist_creator || videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}

+ {/* + {videoMetadata.entries.map((entry) => entry ? ( + + ) : null)} + */} + { + setSelectedPlaylistVideoIndex(value); + setSelectedDownloadFormat('best'); + setSelectedSubtitles([]); + setSelectedCombinableVideoFormat(''); + setSelectedCombinableAudioFormat(''); + resetDownloadConfiguration(); + }} + > + {videoMetadata.entries.map((entry) => entry ? ( + + ) : null)} + +
+ + Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'} +
+
+
+
+ ); +} + +function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, allFilteredFormats, subtitleLanguages, selectedFormat }: SelectivePlaylistDownloadProps) { + const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat); + const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); + const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex); + const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat); + const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); + + return ( +
+ {subtitleLanguages && subtitleLanguages.length > 0 && ( + setSelectedSubtitles(value)} + disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'} + > +

Subtitle Languages

+
+ {subtitleLanguages.map((lang) => ( + + {lang.lang} + + ))} +
+
+ )} + { + setSelectedDownloadFormat(value); + const currentlySelectedFormat = value === 'best' ? videoMetadata?.entries[Number(value) - 1].requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value); + if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') { + setSelectedSubtitles([]); + } + }} + > +

Suggested

+
+ +
+ {qualityPresetFormats && qualityPresetFormats.length > 0 && ( + <> +

Quality Presets

+
+ {qualityPresetFormats.map((format) => ( + + ))} +
+ + )} + {audioOnlyFormats && audioOnlyFormats.length > 0 && ( + <> +

Audio

+
+ {audioOnlyFormats.map((format) => ( + + ))} +
+ + )} + {videoOnlyFormats && videoOnlyFormats.length > 0 && ( + <> +

Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}

+
+ {videoOnlyFormats.map((format) => ( + + ))} +
+ + )} + {combinedFormats && combinedFormats.length > 0 && ( + <> +

Video

+
+ {combinedFormats.map((format) => ( + + ))} +
+ + )} +
+
+
+ ); +} + +function CombinedPlaylistDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLanguages, selectedFormat }: CombinedPlaylistDownloadProps) { + const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat); + const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat); + const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); + const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat); + const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat); + const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); + + return ( +
+ {audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitleLanguages && subtitleLanguages.length > 0 && ( + setSelectedSubtitles(value)} + disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'} + > +

Subtitle Languages

+
+ {subtitleLanguages.map((lang) => ( + + {lang.lang} + + ))} +
+
+ )} + { + setSelectedCombinableAudioFormat(value); + }} + > + {videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && ( + <> +

Audio

+
+ {audioOnlyFormats.map((format) => ( + + ))} +
+ + )} +
+ { + setSelectedCombinableVideoFormat(value); + }} + > + {audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && ( + <> +

Video

+
+ {videoOnlyFormats.map((format) => ( + + ))} +
+ + )} +
+ {(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && ( + + + Unable to use Combine Mode! + + Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video. + + + )} +
+
+ ); +} + +export function PlaylistDownloader({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, allFilteredFormats, subtitleLanguages, selectedFormat }: PlaylistDownloaderProps) { + const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab); + const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab); + const playlistPanelSizes = useDownloaderPageStatesStore((state) => state.playlistPanelSizes); + const setPlaylistPanelSizes = useDownloaderPageStatesStore((state) => state.setPlaylistPanelSizes); + + return ( +
+ setPlaylistPanelSizes(sizes)} + > + + + + + +
+ setActiveDownloadModeTab(tab)} + > +
+

+ + Download Options +

+ + Selective + Combine + +
+ + + + + + +
+
+
+
+
+ ); +} diff --git a/src/components/pages/downloader/videoDownloader.tsx b/src/components/pages/downloader/videoDownloader.tsx new file mode 100644 index 0000000..a310a9d --- /dev/null +++ b/src/components/pages/downloader/videoDownloader.tsx @@ -0,0 +1,379 @@ +import clsx from "clsx"; +import { ProxyImage } from "@/components/custom/proxyImage"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Separator } from "@/components/ui/separator"; +import { useDownloaderPageStatesStore } from "@/services/store"; +import { formatBitrate, formatDurationString, formatReleaseDate, formatYtStyleCount, isObjEmpty } from "@/utils"; +import { Calendar, Clock, DownloadCloud, Eye, Info, ThumbsUp, AlertCircleIcon } from "lucide-react"; +import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { RawVideoInfo, VideoFormat } from "@/types/video"; +// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; + +interface VideoPreviewProps { + videoMetadata: RawVideoInfo; +} + +interface SelectiveVideoDownloadProps { + videoMetadata: RawVideoInfo; + audioOnlyFormats: VideoFormat[] | undefined; + videoOnlyFormats: VideoFormat[] | undefined; + combinedFormats: VideoFormat[] | undefined; + qualityPresetFormats: VideoFormat[] | undefined; + subtitleLanguages: { code: string; lang: string }[]; +} + +interface CombinedVideoDownloadProps { + audioOnlyFormats: VideoFormat[] | undefined; + videoOnlyFormats: VideoFormat[] | undefined; + subtitleLanguages: { code: string; lang: string }[]; +} + +interface VideoDownloaderProps { + videoMetadata: RawVideoInfo; + audioOnlyFormats: VideoFormat[] | undefined; + videoOnlyFormats: VideoFormat[] | undefined; + combinedFormats: VideoFormat[] | undefined; + qualityPresetFormats: VideoFormat[] | undefined; + subtitleLanguages: { code: string; lang: string }[]; +} + +function VideoPreview({ videoMetadata }: VideoPreviewProps) { + return ( +
+

+ + Metadata +

+
+ + + +

{videoMetadata.title ? videoMetadata.title : 'UNTITLED'}

+

{videoMetadata.creator || videoMetadata.channel || videoMetadata.uploader || 'unknown'}

+
+ {videoMetadata.duration_string ? formatDurationString(videoMetadata.duration_string) : 'unknown'} + + {videoMetadata.view_count ? formatYtStyleCount(videoMetadata.view_count) : 'unknown'} + + {videoMetadata.like_count ? formatYtStyleCount(videoMetadata.like_count) : 'unknown'} +
+

+ + {videoMetadata.upload_date ? formatReleaseDate(videoMetadata.upload_date) : 'unknown'} +

+
+ {videoMetadata.resolution && ( + {videoMetadata.resolution} + )} + {videoMetadata.tbr && ( + {formatBitrate(videoMetadata.tbr)} + )} + {videoMetadata.fps && ( + {videoMetadata.fps} fps + )} + {videoMetadata.subtitles && !isObjEmpty(videoMetadata.subtitles) && ( + SUB + )} + {videoMetadata.dynamic_range && videoMetadata.dynamic_range !== 'SDR' && ( + {videoMetadata.dynamic_range} + )} +
+
+ + Extracted from {videoMetadata.extractor ? videoMetadata.extractor.charAt(0).toUpperCase() + videoMetadata.extractor.slice(1) : 'Unknown'} +
+
+
+
+ ); +} + +function SelectiveVideoDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectiveVideoDownloadProps) { + const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat); + const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); + const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat); + const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); + const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey); + + return ( +
+ {subtitleLanguages && subtitleLanguages.length > 0 && ( + setSelectedSubtitles(value)} + // disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'} + > +

Subtitle Languages

+
+ {subtitleLanguages.map((lang) => ( + + {lang.lang} + + ))} +
+
+ )} + { + setSelectedDownloadFormat(value); + // const currentlySelectedFormat = value === 'best' ? videoMetadata?.requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value); + // if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') { + // setSelectedSubtitles([]); + // } + setDownloadConfigurationKey('output_format', null); + setDownloadConfigurationKey('embed_metadata', null); + setDownloadConfigurationKey('embed_thumbnail', null); + setDownloadConfigurationKey('sponsorblock', null); + }} + > +

Suggested

+
+ +
+ {qualityPresetFormats && qualityPresetFormats.length > 0 && ( + <> +

Quality Presets

+
+ {qualityPresetFormats.map((format) => ( + + ))} +
+ + )} + {audioOnlyFormats && audioOnlyFormats.length > 0 && ( + <> +

Audio

+
+ {audioOnlyFormats.map((format) => ( + + ))} +
+ + )} + {videoOnlyFormats && videoOnlyFormats.length > 0 && ( + <> +

Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}

+
+ {videoOnlyFormats.map((format) => ( + + ))} +
+ + )} + {combinedFormats && combinedFormats.length > 0 && ( + <> +

Video

+
+ {combinedFormats.map((format) => ( + + ))} +
+ + )} +
+
+
+ ); +} + +function CombinedVideoDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLanguages }: CombinedVideoDownloadProps) { + const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat); + const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat); + const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); + const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat); + const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat); + const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); + const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey); + + return ( +
+ {audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitleLanguages && subtitleLanguages.length > 0 && ( + setSelectedSubtitles(value)} + > +

Subtitle Languages

+
+ {subtitleLanguages.map((lang) => ( + + {lang.lang} + + ))} +
+
+ )} + { + setSelectedCombinableAudioFormat(value); + setDownloadConfigurationKey('output_format', null); + setDownloadConfigurationKey('embed_metadata', null); + setDownloadConfigurationKey('embed_thumbnail', null); + setDownloadConfigurationKey('sponsorblock', null); + }} + > + {videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && ( + <> +

Audio

+
+ {audioOnlyFormats.map((format) => ( + + ))} +
+ + )} +
+ { + setSelectedCombinableVideoFormat(value); + setDownloadConfigurationKey('output_format', null); + setDownloadConfigurationKey('embed_metadata', null); + setDownloadConfigurationKey('embed_thumbnail', null); + setDownloadConfigurationKey('sponsorblock', null); + }} + > + {audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && ( + <> +

Video

+
+ {videoOnlyFormats.map((format) => ( + + ))} +
+ + )} +
+ {(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && ( + + + Unable to use Combine Mode! + + Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video. + + + )} +
+
+ ); +} + +export function VideoDownloader({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: VideoDownloaderProps) { + const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab); + const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab); + const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey); + const videoPanelSizes = useDownloaderPageStatesStore((state) => state.videoPanelSizes); + const setVideoPanelSizes = useDownloaderPageStatesStore((state) => state.setVideoPanelSizes); + + return ( +
+ setVideoPanelSizes(sizes)} + > + + + + + +
+ { + setActiveDownloadModeTab(tab) + setDownloadConfigurationKey('output_format', null); + setDownloadConfigurationKey('embed_metadata', null); + setDownloadConfigurationKey('embed_thumbnail', null); + setDownloadConfigurationKey('sponsorblock', null); + }} + > +
+

+ + Download Options +

+ + Selective + Combine + +
+ + + + + + +
+
+
+
+
+ ); +} diff --git a/src/components/pages/library/completedDownloads.tsx b/src/components/pages/library/completedDownloads.tsx new file mode 100644 index 0000000..fe400f2 --- /dev/null +++ b/src/components/pages/library/completedDownloads.tsx @@ -0,0 +1,286 @@ +import { ProxyImage } from "@/components/custom/proxyImage"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; +import { useCurrentVideoMetadataStore, useDownloadActionStatesStore } from "@/services/store"; +import { formatBitrate, formatCodec, formatDurationString, formatFileSize } from "@/utils"; +import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import * as fs from "@tauri-apps/plugin-fs"; +import { DownloadState } from "@/types/download"; +import { useQueryClient } from "@tanstack/react-query"; +import { useDeleteDownloadState } from "@/services/mutations"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { useNavigate } from "react-router-dom"; +import { useLogger } from "@/helpers/use-logger"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; + +interface CompletedDownloadProps { + state: DownloadState; +} + +interface CompletedDownloadsProps { + downloads: DownloadState[]; +} + +export function CompletedDownload({ state }: CompletedDownloadProps) { + const downloadActions = useDownloadActionStatesStore(state => state.downloadActions); + const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked); + + const queryClient = useQueryClient(); + const downloadStateDeleter = useDeleteDownloadState(); + const navigate = useNavigate(); + const LOG = useLogger(); + + const openFile = async (filePath: string | null, app: string | null) => { + if (filePath && await fs.exists(filePath)) { + try { + await invoke('open_file_with_app', { filePath: filePath, appName: app }).then(() => { + toast.info(`${app === 'explorer' ? 'Revealing' : 'Opening'} file`, { + description: `${app === 'explorer' ? 'Revealing' : 'Opening'} the file ${app === 'explorer' ? 'in' : 'with'} ${app ? app : 'default app'}.`, + }) + }); + } catch (e) { + console.error(e); + toast.error(`Failed to ${app === 'explorer' ? 'reveal' : 'open'} file`, { + description: `An error occurred while trying to ${app === 'explorer' ? 'reveal' : 'open'} the file.`, + }) + } + } else { + toast.info("File unavailable", { + description: `The file you are trying to ${app === 'explorer' ? 'reveal' : 'open'} does not exist.`, + }) + } + } + + const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => { + if (delete_file && downloadState.filepath) { + try { + if (await fs.exists(downloadState.filepath)) { + await fs.remove(downloadState.filepath); + } else { + console.error(`File not found: "${downloadState.filepath}"`); + } + } catch (e) { + console.error(e); + } + } + + downloadStateDeleter.mutate(downloadState.download_id, { + onSuccess: (data) => { + console.log("Download State deleted successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + if (delete_file && downloadState.filepath) { + toast.success("Deleted from downloads", { + description: `The download for "${downloadState.title}" has been deleted successfully.`, + }); + } else { + toast.success("Removed from downloads", { + description: `The download for "${downloadState.title}" has been removed successfully.`, + }); + } + }, + onError: (error) => { + console.error("Failed to delete download state:", error); + if (delete_file && downloadState.filepath) { + toast.error("Failed to delete download", { + description: `An error occurred while trying to delete the download for "${downloadState.title}".`, + }); + } else { + toast.error("Failed to remove download", { + description: `An error occurred while trying to remove the download for "${downloadState.title}".`, + }); + } + } + }) + } + + const handleSearch = async (url: string, isPlaylist: boolean) => { + try { + LOG.info('NEODLP', `Received search request from library for URL: ${url}`); + navigate('/'); + const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState(); + setRequestedUrl(url); + setAutoSubmitSearch(true); + toast.info(`Initiating ${isPlaylist ? 'Playlist' : 'Video'} Search`, { + description: `Initiating search for the selected ${isPlaylist ? 'playlist' : 'video'}.`, + }); + } catch (e) { + console.error(e); + toast.error("Failed to initiate search", { + description: "An error occurred while trying to initiate the search.", + }); + } + } + + const itemActionStates = downloadActions[state.download_id] || { + isResuming: false, + isPausing: false, + isCanceling: false, + isDeleteFileChecked: false, + }; + + return ( +
+
+ + + + + {state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && ( + +
+
+
+

{state.title}

+

{state.channel ? state.channel : 'unknown'} {state.host ? <> {state.host} : 'unknown'}

+
+ {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'} + + + {state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && ( + + )} + {state.filetype && state.filetype === 'audio' && ( + + )} + {(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && ( + + )} + {state.filesize ? formatFileSize(state.filesize) : 'unknown'} + + + + {state.vbr && state.abr ? ( + formatBitrate(state.vbr + state.abr) + ) : state.vbr ? ( + formatBitrate(state.vbr) + ) : state.abr ? ( + formatBitrate(state.abr) + ) : ( + 'unknown' + )} + +
+
+ {state.playlist_id && state.playlist_index && ( + + Playlist ({state.playlist_index} of {state.playlist_n_entries}) + + )} + {state.vcodec && ( + {formatCodec(state.vcodec)} + )} + {state.acodec && ( + {formatCodec(state.acodec)} + )} + {state.dynamic_range && state.dynamic_range !== 'SDR' && ( + {state.dynamic_range} + )} + {state.subtitle_id && ( + + ESUB + + )} +
+
+
+ + + + + + + + + + Remove from library? + + Are you sure you want to remove this download from the library? You can also delete the downloaded file by cheking the box below. This action cannot be undone. + +
+ {setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} /> + +
+
+ + Cancel + removeFromDownloads(state, itemActionStates.isDeleteFileChecked).then(() => { + setIsDeleteFileChecked(state.download_id, false); + }) + }>Remove + +
+
+
+
+
+ ); +} + +export function CompletedDownloads({ downloads }: CompletedDownloadsProps) { + const navigate = useNavigate(); + + return ( +
+ {downloads.length > 0 ? ( + downloads.map((state) => { + return ( + + ); + }) + ) : ( + + + + + + No Completed Downloads + + You have not completed any downloads yet! Complete downloading something to see here :) + + + + + )} +
+ ); +} diff --git a/src/components/pages/library/incompleteDownloads.tsx b/src/components/pages/library/incompleteDownloads.tsx new file mode 100644 index 0000000..c0b0a83 --- /dev/null +++ b/src/components/pages/library/incompleteDownloads.tsx @@ -0,0 +1,233 @@ +import { IndeterminateProgress } from "@/components/custom/indeterminateProgress"; +import { ProxyImage } from "@/components/custom/proxyImage"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { toast } from "sonner"; +import { useAppContext } from "@/providers/appContextProvider"; +import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store"; +import { formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils"; +import { ArrowUpRightIcon, CircleCheck, File, Loader2, Music, Pause, Play, Video, X } from "lucide-react"; +import { DownloadState } from "@/types/download"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; +import { useNavigate } from "react-router-dom"; + +interface IncompleteDownloadProps { + state: DownloadState; +} + +interface IncompleteDownloadsProps { + downloads: DownloadState[]; +} + +export function IncompleteDownload({ state }: IncompleteDownloadProps) { + const downloadActions = useDownloadActionStatesStore(state => state.downloadActions); + const setIsResumingDownload = useDownloadActionStatesStore(state => state.setIsResumingDownload); + const setIsPausingDownload = useDownloadActionStatesStore(state => state.setIsPausingDownload); + const setIsCancelingDownload = useDownloadActionStatesStore(state => state.setIsCancelingDownload); + + const debugMode = useSettingsPageStatesStore(state => state.settings.debug_mode); + + const { pauseDownload, resumeDownload, cancelDownload } = useAppContext() + + const itemActionStates = downloadActions[state.download_id] || { + isResuming: false, + isPausing: false, + isCanceling: false, + isDeleteFileChecked: false, + }; + + return ( +
+
+ + + + {state.ext ? ( + + {state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && ( + + ) : ( + + {state.download_status === 'starting' ? ( + <> Processing... + ) : ( + <> Unknown + )} + + )} +
+
+
+

{state.title}

+ {((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && ( + + )} + {(state.download_status === 'downloading' || state.download_status === 'paused') && state.progress && state.status !== 'finished' && ( +
+ {state.progress}% + + { + state.downloaded && state.total + ? `(${formatFileSize(state.downloaded)} / ${formatFileSize(state.total)})` + : null + } +
+ )} +
+ {state.download_status && state.download_status === 'downloading' && state.status === 'finished' ? 'Processing' : state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)} {debugMode && state.download_id ? <> ID: {state.download_id.toUpperCase()} : ""} {state.download_status === 'downloading' && state.status !== 'finished' && state.speed ? <> Speed: {formatSpeed(state.speed)} : ""} {state.download_status === 'downloading' && state.eta ? <> ETA: {formatSecToTimeString(state.eta)} : ""} +
+
+
+ {state.download_status === 'paused' ? ( + + ) : ( + + )} + +
+
+
+ ); +} + +export function IncompleteDownloads({ downloads }: IncompleteDownloadsProps) { + const navigate = useNavigate(); + + return ( +
+ {downloads.length > 0 ? ( + downloads.map((state) => { + return ( + + ); + }) + ) : ( + + + + + + No Incomplete Downloads + + You have all caught up! Sit back and relax or just spin up a new download to see here :) + + + + + )} +
+ ); +} diff --git a/src/components/pages/settings/applicationSettings.tsx b/src/components/pages/settings/applicationSettings.tsx new file mode 100644 index 0000000..d2d2207 --- /dev/null +++ b/src/components/pages/settings/applicationSettings.tsx @@ -0,0 +1,1456 @@ +import { useEffect } from "react"; +import { Card } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useBasePathsStore, useDownloaderPageStatesStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { BellRing, BrushCleaning, Bug, Cookie, ExternalLink, FileVideo, Folder, FolderOpen, Info, Loader2, LucideIcon, Monitor, Moon, ShieldMinus, SquareTerminal, Sun, Terminal, Trash, TriangleAlert, WandSparkles, Wifi, Wrench } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Slider } from "@/components/ui/slider"; +import { Input } from "@/components/ui/input"; +import { open } from '@tauri-apps/plugin-dialog'; +import { useSettings } from "@/helpers/use-settings"; +import { useYtDlpUpdater } from "@/helpers/use-ytdlp-updater"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import * as fs from "@tauri-apps/plugin-fs"; +import { join } from "@tauri-apps/api/path"; +import { formatSpeed, generateID } from "@/utils"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Textarea } from "@/components/ui/textarea"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification'; + +const proxyUrlSchema = z.object({ + url: z.url({ + error: (issue) => issue.input === undefined || issue.input === null || issue.input === "" + ? "Proxy URL is required" + : "Invalid URL format" + }) +}); + +const rateLimitSchema = z.object({ + rate_limit: z.coerce.number({ + error: (issue) => issue.input === undefined || issue.input === null || issue.input === "" + ? "Rate Limit is required" + : "Rate Limit must be a valid number" + }).int({ + message: "Rate Limit must be an integer" + }).min(1024, { + message: "Rate Limit must be at least 1024 bytes/s (1 KB/s)" + }).max(104857600, { + message: "Rate Limit must be at most 104857600 bytes/s (100 MB/s)" + }), +}); + +const addCustomCommandSchema = z.object({ + label: z.string().min(1, { message: "Label is required" }), + args: z.string().min(1, { message: "Arguments are required" }), +}); + +const filenameTemplateShcema = z.object({ + template: z.string().min(1, { message: "Filename Template is required" }), +}); + +function AppGeneralSettings() { + const { saveSettingsKey } = useSettings(); + + const maxParallelDownloads = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads); + const maxRetries = useSettingsPageStatesStore(state => state.settings.max_retries); + const preferVideoOverPlaylist = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist); + const strictDownloadabilityCheck = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check); + const useAria2 = useSettingsPageStatesStore(state => state.settings.use_aria2); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + return ( + <> +
+

Max Parallel Downloads

+

Set maximum number of allowed parallel downloads

+ saveSettingsKey('max_parallel_downloads', value[0])} + /> + +
+
+

Prefer Video Over Playlist

+

Prefer only the video, if the URL refers to a video and a playlist

+ saveSettingsKey('prefer_video_over_playlist', checked)} + /> +
+
+

Strict Downloadablity Check

+

Only show streams that are actualy downloadable, also check formats before downloading (high quality results, takes longer time to search)

+ saveSettingsKey('strict_downloadablity_check', checked)} + /> +
+
+

Max Retries

+

Set maximum number of retries for a download before giving up

+ saveSettingsKey('max_retries', value[0])} + /> + +
+
+

Aria2

+

Use aria2c as external downloader (recommended only if you are experiancing too slow download speeds with native downloader, you need to install aria2 via homebrew if you are on macos to use this feature)

+ saveSettingsKey('use_aria2', checked)} + disabled={useCustomCommands} + /> +
+ + ); +} + +function AppAppearanceSettings() { + const { saveSettingsKey } = useSettings(); + + const appTheme = useSettingsPageStatesStore(state => state.settings.theme); + const appColorScheme = useSettingsPageStatesStore(state => state.settings.color_scheme); + + const themeOptions: { value: string; icon: LucideIcon; label: string }[] = [ + { value: 'light', icon: Sun, label: 'Light' }, + { value: 'dark', icon: Moon, label: 'Dark' }, + { value: 'system', icon: Monitor, label: 'System' }, + ]; + + const colorSchemeOptions: { value: string; label: string }[] = [ + { value: 'default', label: 'Default' }, + { value: 'blue', label: 'Blue' }, + { value: 'green', label: 'Green' }, + { value: 'orange', label: 'Orange' }, + { value: 'red', label: 'Red' }, + { value: 'rose', label: 'Rose' }, + { value: 'violet', label: 'Violet' }, + { value: 'yellow', label: 'Yellow' }, + ]; + + return ( + <> +
+

Theme

+

Choose app interface theme

+
+ {themeOptions.map(({ value, icon: Icon, label }) => ( + + ))} +
+
+
+

Color Scheme

+

Choose app interface color scheme

+ saveSettingsKey('color_scheme', value)} + > +
+ {colorSchemeOptions.map(({ value, label }) => ( + + + { + + } + {label} + + + ))} +
+
+
+ + ); +} + +function AppFolderSettings() { + const { saveSettingsKey } = useSettings(); + + const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger); + const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset); + + const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath); + const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath); + const setPath = useBasePathsStore((state) => state.setPath); + + const filenameTemplate = useSettingsPageStatesStore(state => state.settings.filename_template); + + const downloadStates = useDownloadStatesStore(state => state.downloadStates); + const ongoingDownloads = downloadStates.filter(state => + ['starting', 'downloading', 'queued'].includes(state.download_status) + ); + + const cleanTemporaryDownloads = async () => { + const tempFiles = await fs.readDir(tempDownloadDirPath ?? ''); + if (tempFiles.length > 0) { + try { + for (const file of tempFiles) { + if (file.isFile) { + const filePath = await join(tempDownloadDirPath ?? '', file.name); + await fs.remove(filePath); + } + } + toast.success("Temporary Downloads Cleaned", { + description: "All temporary downloads have been successfully cleaned up.", + }); + } catch (e) { + toast.error("Temporary Downloads Cleanup Failed", { + description: "An error occurred while trying to clean up temporary downloads. Please try again.", + }); + } + } else { + toast.info("No Temporary Downloads", { + description: "There are no temporary downloads to clean up.", + }); + } + } + + const filenameTemplateForm = useForm>({ + resolver: zodResolver(filenameTemplateShcema), + defaultValues: { + template: filenameTemplate, + }, + mode: "onChange", + }); + const watchedFilenameTemplate = filenameTemplateForm.watch("template"); + const { errors: filenameTemplateFormErrors } = filenameTemplateForm.formState; + + function handleFilenameTemplateSubmit(values: z.infer) { + try { + saveSettingsKey('filename_template', values.template); + toast.success("Filename Template updated", { + description: `Filename Template changed to ${values.template}`, + }); + } catch (error) { + console.error("Error changing filename template:", error); + toast.error("Failed to change filename template", { + description: "An error occurred while trying to change the filename template. Please try again.", + }); + } + } + + useEffect(() => { + if (formResetTrigger > 0) { + filenameTemplateForm.reset(); + acknowledgeFormReset(); + } + }, [formResetTrigger]); + + return ( + <> +
+

Download Folder

+

Set default download folder (directory)

+
+ + +
+
+
+

Temporary Download Folder

+

Clean up temporary downloads (broken, cancelled, paused downloads)

+
+ + + + + + + + Clean up all temporary downloads? + Are you sure you want to clean up all temporary downloads? This will remove all broken, cancelled and paused downloads from the temporary folder. Paused downloads will re-start from the begining. This action cannot be undone! + + + Cancel + cleanTemporaryDownloads()} + >Clean + + + +
+
+
+

Filename Template

+

Set the template for naming downloaded files (download id and file extension will be auto-appended at the end, changing template may cause paused downloads to re-start from begining)

+
+ + ( + + + + + + + )} + /> + + + +
+ + ); +} + +function AppFormatSettings() { + const { saveSettingsKey } = useSettings(); + + const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format); + const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format); + const alwaysReencodeVideo = useSettingsPageStatesStore(state => state.settings.always_reencode_video); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + return ( + <> +
+

Video Format

+

Choose in which format the final video file will be saved

+ saveSettingsKey('video_format', value)} + disabled={useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Audio Format

+

Choose in which format the final audio file will be saved

+ saveSettingsKey('audio_format', value)} + disabled={useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+

Always Re-Encode Video

+

Instead of remuxing (simple container change) always re-encode the video to the target format with best compatible codecs (better compatibility, takes longer processing time)

+ saveSettingsKey('always_reencode_video', checked)} + disabled={useCustomCommands} + /> +
+ + ); +} + +function AppMetadataSettings() { + const { saveSettingsKey } = useSettings(); + + const embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata); + const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata); + const embedVideoThumbnail = useSettingsPageStatesStore(state => state.settings.embed_video_thumbnail); + const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + return ( + <> +
+

Embed Metadata

+

Wheather to embed metadata in video/audio files (info, chapters)

+
+ saveSettingsKey('embed_video_metadata', checked)} + disabled={useCustomCommands} + /> + +
+
+ saveSettingsKey('embed_audio_metadata', checked)} + disabled={useCustomCommands} + /> + +
+
+
+

Embed Thumbnail

+

Wheather to embed thumbnail in video/audio files (as cover art)

+
+ saveSettingsKey('embed_video_thumbnail', checked)} + disabled={useCustomCommands} + /> + +
+
+ saveSettingsKey('embed_audio_thumbnail', checked)} + disabled={useCustomCommands} + /> + +
+
+ + ); +} + +function AppNetworkSettings() { + const { saveSettingsKey } = useSettings(); + + const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger); + const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset); + + const useProxy = useSettingsPageStatesStore(state => state.settings.use_proxy); + const proxyUrl = useSettingsPageStatesStore(state => state.settings.proxy_url); + const useRateLimit = useSettingsPageStatesStore(state => state.settings.use_rate_limit); + const rateLimit = useSettingsPageStatesStore(state => state.settings.rate_limit); + const useForceInternetProtocol = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol); + const forceInternetProtocol = useSettingsPageStatesStore(state => state.settings.force_internet_protocol); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + const proxyUrlForm = useForm>({ + resolver: zodResolver(proxyUrlSchema), + defaultValues: { + url: proxyUrl, + }, + mode: "onChange", + }); + const watchedProxyUrl = proxyUrlForm.watch("url"); + const { errors: proxyUrlFormErrors } = proxyUrlForm.formState; + + function handleProxyUrlSubmit(values: z.infer) { + try { + saveSettingsKey('proxy_url', values.url); + toast.success("Proxy URL updated", { + description: `Proxy URL changed to ${values.url}`, + }); + } catch (error) { + console.error("Error changing proxy URL:", error); + toast.error("Failed to change proxy URL", { + description: "An error occurred while trying to change the proxy URL. Please try again.", + }); + } + } + + const rateLimitForm = useForm>({ + resolver: zodResolver(rateLimitSchema), + defaultValues: { + rate_limit: rateLimit, + }, + mode: "onChange", + }); + const watchedRateLimit = rateLimitForm.watch("rate_limit"); + const { errors: rateLimitFormErrors } = rateLimitForm.formState; + + function handleRateLimitSubmit(values: z.infer) { + try { + saveSettingsKey('rate_limit', values.rate_limit); + toast.success("Rate Limit updated", { + description: `Rate Limit changed to ${values.rate_limit} bytes/s`, + }); + } catch (error) { + console.error("Error changing rate limit:", error); + toast.error("Failed to change rate limit", { + description: "An error occurred while trying to change the rate limit. Please try again.", + }); + } + } + + useEffect(() => { + if (formResetTrigger > 0) { + proxyUrlForm.reset(); + rateLimitForm.reset(); + acknowledgeFormReset(); + } + }, [formResetTrigger]); + + return ( + <> +
+

Proxy

+

Use proxy for downloads, Unblocks blocked sites in your region (download speed may affect, some sites may not work)

+
+ saveSettingsKey('use_proxy', checked)} + disabled={useCustomCommands} + /> + +
+
+ + ( + + + + + + + + )} + /> + + + +
+
+

Rate Limit

+

Limit download speed to prevent network congestion. Rate limit is applied per-download basis (not in the whole app)

+
+ saveSettingsKey('use_rate_limit', checked)} + disabled={useCustomCommands} + /> + +
+
+ + ( + + + + + + + + )} + /> + + + +
+
+

Force Internet Protocol

+

Force use a specific internet protocol (ipv4/ipv6) for all downloads, useful if your network supports only one (some sites may not work)

+
+ saveSettingsKey('use_force_internet_protocol', checked)} + disabled={useCustomCommands} + /> + +
+ saveSettingsKey('force_internet_protocol', value)} + disabled={!useForceInternetProtocol || useCustomCommands} + > +
+ + +
+
+ + +
+
+ +
+ + ); +} + +function AppCookiesSettings() { + const { saveSettingsKey } = useSettings(); + + const useCookies = useSettingsPageStatesStore(state => state.settings.use_cookies); + const importCookiesFrom = useSettingsPageStatesStore(state => state.settings.import_cookies_from); + const cookiesBrowser = useSettingsPageStatesStore(state => state.settings.cookies_browser); + const cookiesFile = useSettingsPageStatesStore(state => state.settings.cookies_file); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + return ( + <> +
+

Cookies

+

Use cookies to access exclusive/private (login-protected) contents from sites (use wisely, over-use can even block/ban your account)

+
+ saveSettingsKey('use_cookies', checked)} + disabled={useCustomCommands} + /> + +
+ saveSettingsKey('import_cookies_from', value)} + disabled={!useCookies || useCustomCommands} + > +
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+ +
+ + ); +} + +function AppSponsorblockSettings() { + const { saveSettingsKey } = useSettings(); + + const useSponsorblock = useSettingsPageStatesStore(state => state.settings.use_sponsorblock); + const sponsorblockMode = useSettingsPageStatesStore(state => state.settings.sponsorblock_mode); + const sponsorblockRemove = useSettingsPageStatesStore(state => state.settings.sponsorblock_remove); + const sponsorblockMark = useSettingsPageStatesStore(state => state.settings.sponsorblock_mark); + const sponsorblockRemoveCategories = useSettingsPageStatesStore(state => state.settings.sponsorblock_remove_categories); + const sponsorblockMarkCategories = useSettingsPageStatesStore(state => state.settings.sponsorblock_mark_categories); + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + + const sponsorblockCategories: { code: string; label: string }[] = [ + { code: 'sponsor', label: 'Sponsorship' }, + { code: 'intro', label: 'Intro' }, + { code: 'outro', label: 'Outro' }, + { code: 'interaction', label: 'Interaction' }, + { code: 'selfpromo', label: 'Self Promotion' }, + { code: 'music_offtopic', label: 'Music Offtopic' }, + { code: 'preview', label: 'Preview' }, + { code: 'filler', label: 'Filler' }, + { code: 'poi_highlight', label: 'Point of Interest' }, + { code: 'chapter', label: 'Chapter' }, + { code: 'hook', label: 'Hook' }, + ]; + + return ( + <> +
+

Sponsor Block

+

Use sponsorblock to remove/mark unwanted segments in videos (sponsorships, intros, outros, etc.)

+
+ saveSettingsKey('use_sponsorblock', checked)} + disabled={useCustomCommands} + /> + +
+ saveSettingsKey('sponsorblock_mode', value)} + disabled={!useSponsorblock || useCustomCommands} + > +
+ + +
+
+ + +
+
+
+ + saveSettingsKey('sponsorblock_remove', value)} + disabled={/*!useSponsorblock || sponsorblockMode !== "remove" ||*/ useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ cat.code !== 'poi_highlight' && cat.code !== 'filler').map((cat) => cat.code) : sponsorblockRemove === "all" ? sponsorblockCategories.filter((cat) => cat.code !== 'poi_highlight').map((cat) => cat.code) : []} + onValueChange={(value) => saveSettingsKey('sponsorblock_remove_categories', value)} + disabled={/*!useSponsorblock || sponsorblockMode !== "remove" ||*/ sponsorblockRemove !== "custom" || useCustomCommands} + > +
+ {sponsorblockCategories.map((category) => ( + category.code !== "poi_highlight" && ( + + {category.label} + + ) + ))} +
+
+
+
+ + saveSettingsKey('sponsorblock_mark', value)} + disabled={/*!useSponsorblock || sponsorblockMode !== "mark" ||*/ useCustomCommands} + > +
+ + +
+
+ + +
+
+ + +
+
+ cat.code) : sponsorblockMark === "all" ? sponsorblockCategories.map((cat) => cat.code) : []} + onValueChange={(value) => saveSettingsKey('sponsorblock_mark_categories', value)} + disabled={/*!useSponsorblock || sponsorblockMode !== "mark" ||*/ sponsorblockMark !== "custom" || useCustomCommands} + > +
+ {sponsorblockCategories.map((category) => ( + + {category.label} + + ))} +
+
+
+ +
+ + ); +} + +function AppNotificationSettings() { + const { saveSettingsKey } = useSettings(); + + const enableNotifications = useSettingsPageStatesStore(state => state.settings.enable_notifications); + const updateNotification = useSettingsPageStatesStore(state => state.settings.update_notification); + const downloadCompletionNotification = useSettingsPageStatesStore(state => state.settings.download_completion_notification); + + return ( + <> +
+

Desktop Notifications

+

Enable desktop notifications for app events (updates, download completions, etc.)

+
+ { + if (checked) { + const granted = await isPermissionGranted(); + if (!granted) { + const permission = await requestPermission(); + if (permission !== 'granted') { + toast.error("Notification Permission Denied", { + description: "You have denied the notification permission. Please enable it from your system settings to receive notifications.", + }); + return; + } + } + } + saveSettingsKey('enable_notifications', checked) + }} + /> + +
+
+ +
+ saveSettingsKey('update_notification', checked)} + disabled={!enableNotifications} + /> + +
+
+ saveSettingsKey('download_completion_notification', checked)} + disabled={!enableNotifications} + /> + +
+
+
+ + ); +} + +function AppCommandSettings() { + const { saveSettingsKey } = useSettings(); + + const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger); + const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset); + + const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands); + const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands); + + const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey); + const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration); + + const addCustomCommandForm = useForm>({ + resolver: zodResolver(addCustomCommandSchema), + defaultValues: { + label: '', + args: '', + }, + mode: "onChange", + }); + const watchedLabel = addCustomCommandForm.watch("label"); + const watchedArgs = addCustomCommandForm.watch("args"); + const { errors: addCustomCommandFormErrors } = addCustomCommandForm.formState; + + function handleAddCustomCommandSubmit(values: z.infer) { + try { + const newCommand = { + id: generateID(), + label: values.label, + args: values.args, + }; + const updatedCommands = [...customCommands, newCommand]; + saveSettingsKey('custom_commands', updatedCommands); + toast.success("Custom Command added", { + description: `Custom Command "${values.label}" added.`, + }); + addCustomCommandForm.reset(); + } catch (error) { + console.error("Error adding custom command:", error); + toast.error("Failed to add custom command", { + description: "An error occurred while trying to add the custom command. Please try again.", + }); + } + } + + function handleRemoveCustomCommandSubmit(commandId: string) { + try { + const removedCommand = customCommands.find(command => command.id === commandId); + const updatedCommands = customCommands.filter(command => command.id !== commandId); + saveSettingsKey('custom_commands', updatedCommands); + setDownloadConfigurationKey('custom_command', null); + toast.success("Custom Command removed", { + description: `Custom Command "${removedCommand?.label}" removed.`, + }); + } catch (error) { + console.error("Error removing custom command:", error); + toast.error("Failed to remove custom command", { + description: "An error occurred while trying to remove the custom command. Please try again.", + }); + } + } + + useEffect(() => { + if (formResetTrigger > 0) { + addCustomCommandForm.reset(); + acknowledgeFormReset(); + } + }, [formResetTrigger]); + + return ( + <> +
+

Custom Commands

+

Run custom yt-dlp commands for your downloads

+ + + Most Settings will be Disabled! + + This feature is intended for advanced users only. Turning it on will disable most other settings in the app. Make sure you know what you are doing before using this feature, otherwise things could break easily. + + +
+ { + saveSettingsKey('use_custom_commands', checked) + resetDownloadConfiguration(); + }} + /> + +
+
+
+ + ( + + +