diff --git a/src/constants.js b/src/constants.js index e7d9c8c99b646..d9a6283b4cf5a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -46,8 +46,15 @@ const IpcChannels = { CHOOSE_DEFAULT_FOLDER: 'choose-default-folder', WRITE_TO_DEFAULT_FOLDER: 'write-to-default-folder', + CHOOSE_YTDLP_OUTPUT_DIRECTORY: 'choose-ytdlp-output-directory', + CHOOSE_YTDLP_EXECUTABLE: 'choose-ytdlp-executable', + FIND_EXECUTABLE_ON_PATH: 'find-executable-on-path', + GET_DOWNLOADER_EXECUTABLE_VERSIONS: 'get-downloader-executable-versions', + OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player', - OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result' + OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result', + + DOWNLOAD_VIDEO: 'download-video' } const DBActions = { diff --git a/src/main/download.js b/src/main/download.js new file mode 100644 index 0000000000000..c614e5472218e --- /dev/null +++ b/src/main/download.js @@ -0,0 +1,305 @@ +import { app, BrowserWindow, dialog } from 'electron' +import { execFile, spawn } from 'node:child_process' +import { access, constants } from 'node:fs/promises' +import { normalize } from 'node:path' +import { promisify } from 'node:util' +import { settings } from '../datastores/handlers/base' +import { isFreeTubeUrl } from './utils' + +const execFileAsync = promisify(execFile) + +const ID_REGEX = /^[\w-]+$/ + +/** + * @param {string} path + * @returns {Promise} + */ +async function isExecutable(path) { + try { + await access(path, constants.X_OK) + return true + } catch { + return false + } +} + +/** + * @param {string} name + * @returns {Promise} + */ +export async function findExecutableOnPath(name) { + try { + const { stdout } = process.platform === 'win32' + ? await execFileAsync('where', [name]) + : await execFileAsync('which', [name]) + + return stdout.split(/\r?\n/)[0].trim() || null + } catch { + return null + } +} + +/** + * @param {string} name + * @param {string} currentPath + * @returns {Promise} + */ +export async function resolveExecutable(name, currentPath) { + if (currentPath.length > 0 && await isExecutable(currentPath)) { + return currentPath + } + + return findExecutableOnPath(name) +} + +/** + * @param {string} executable + * @param {string[]} versionArgs + * @returns {Promise} + */ +async function getVersion(executable, versionArgs) { + if (executable.length === 0 || !await isExecutable(executable)) { + return null + } + + try { + const { stdout } = await execFileAsync(executable, versionArgs) + return stdout.split(/\r?\n/)[0].trim() || null + } catch { + return null + } +} + +/** + * @param {string} ytdlpExecutable + * @returns {Promise<{ ytdlp: string | null }>} + */ +export async function getExecutableVersions(ytdlpExecutable) { + const ytdlp = await getVersion(ytdlpExecutable, ['--version']) + + return { ytdlp } +} + +/** + * Terminal emulators to try on Linux, in order, along with how each one + * expects the command to run to be passed. + * @type {{ name: string, buildArgs: (shellCommand: string) => string[] }[]} + */ +const LINUX_TERMINALS = [ + { name: 'x-terminal-emulator', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, + { name: 'gnome-terminal', buildArgs: (shellCommand) => ['--', 'sh', '-c', shellCommand] }, + { name: 'konsole', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, + { name: 'xfce4-terminal', buildArgs: (shellCommand) => ['-x', 'sh', '-c', shellCommand] }, + { name: 'kitty', buildArgs: (shellCommand) => ['sh', '-c', shellCommand] }, + { name: 'alacritty', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, + { name: 'xterm', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, +] + +/** + * @returns {Promise<{ name: string, buildArgs: (shellCommand: string) => string[] } | null>} + */ +async function findLinuxTerminal() { + for (const terminal of LINUX_TERMINALS) { + if (await findExecutableOnPath(terminal.name)) { + return terminal + } + } + + return null +} + +/** + * @param {string} path + * @returns {Promise} + */ +async function hasWriteAccess(path) { + try { + await access(path, constants.W_OK) + return true + } catch { + return false + } +} + +/** + * @param {import('electron').WebContents} webContents + * @param {string | undefined} [defaultPath] + * @returns {Promise} + */ +async function promptForOutputDirectory(webContents, defaultPath) { + const dialogOptions = { + defaultPath: typeof defaultPath === 'string' && defaultPath.length > 0 ? defaultPath : app.getPath('downloads'), + properties: ['openDirectory'] + } + + const window = BrowserWindow.fromWebContents(webContents) + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled) { + return null + } + + return result.filePaths[0] +} + +/** + * @typedef {'ok' | 'invalid' | 'not-configured' | 'disabled' | 'cancelled' | 'error'} DownloadVideoResult + */ + +/** + * @param {import('electron').IpcMainInvokeEvent} event + * @param {{ videoId: string, mode: 'video' | 'audio', startTime: number | null | undefined, endTime: number | null | undefined }} payload + * @returns {Promise} + */ +export async function handleDownloadVideo(event, payload) { + if (!isFreeTubeUrl(event.senderFrame.url) || !event.sender.isFocused()) { + return 'invalid' + } + + const { videoId, mode, startTime, endTime } = payload ?? {} + + if (typeof videoId !== 'string' || videoId.length !== 11 || !ID_REGEX.test(videoId)) { + return 'invalid' + } + + if (mode !== 'video' && mode !== 'audio') { + return 'invalid' + } + + /** @type {boolean} */ + const downloadEnabled = (await settings._findOne('ytdlpDownloadEnabled'))?.value || false + + if (!downloadEnabled) { + return 'disabled' + } + + const hasValidStartTime = typeof startTime === 'number' && startTime >= 0 + const hasValidEndTime = typeof endTime === 'number' && endTime > 0 + + /** @type {string} */ + const executable = (await settings._findOne('ytdlpExecutable'))?.value || '' + + if (executable.length === 0) { + return 'not-configured' + } + + /** @type {string} */ + const downloadMode = (await settings._findOne('ytdlpDownloadMode'))?.value || 'prompt_folder' + + /** @type {string} */ + const storedOutputDirectory = (await settings._findOne('ytdlpOutputDirectory'))?.value || '' + + const canUseStoredDirectory = downloadMode === 'default_folder' && storedOutputDirectory.length > 0 && + await hasWriteAccess(normalize(storedOutputDirectory)) + + // Either "always ask" mode, or the stored folder is unset/no longer writable + // (e.g. a Flatpak-portal-granted folder that got revoked) - prompt for one. + const outputDirectory = canUseStoredDirectory + ? storedOutputDirectory + : await promptForOutputDirectory(event.sender, storedOutputDirectory) + + if (!outputDirectory) { + return 'cancelled' + } + + const customArgsSettingId = mode === 'audio' ? 'ytdlpAudioCustomArgs' : 'ytdlpVideoCustomArgs' + + /** @type {string} */ + const customArgs = (await settings._findOne(customArgsSettingId))?.value || '' + + const videoUrl = `https://www.youtube.com/watch?v=${videoId}` + + const args = ['-o', `${outputDirectory}/%(title)s.%(ext)s`] + + if (hasValidStartTime || hasValidEndTime) { + const start = hasValidStartTime ? startTime : 0 + const end = hasValidEndTime ? endTime : 'inf' + args.push('--download-sections', `*${start}-${end}`) + } + + if (mode === 'audio') { + args.push('-x') + } + + if (customArgs.trim().length > 0) { + args.push(...customArgs.trim().split(/\s+/)) + } + + args.push(videoUrl) + + const fullCommand = [executable, ...args] + + if (process.platform === 'win32') { + // echo doesn't parse quotes, so the display line is only quoted where a part has a space + const displayCommand = fullCommand.map(part => part.includes(' ') ? `"${part}"` : part).join(' ') + // cmd /k only strips quotes if they enclose the whole string, so wrap it twice + const runCommand = fullCommand.map(part => `"${part.replaceAll('"', '""')}"`).join(' ') + const innerCommand = `echo ${displayCommand} && ${runCommand}` + + return spawnAndAwait('cmd.exe', ['/c', 'start', '""', '/wait', 'cmd.exe', '/k', `"${innerCommand}"`], { + windowsVerbatimArguments: true + }) + } + + const shellCommand = `echo ${quoteForShellDisplay(fullCommand)} && exec ${quoteForShell(fullCommand)}` + + if (process.platform === 'darwin') { + const appleScript = `tell application "Terminal" to do script ${quoteForAppleScript(shellCommand)}` + return spawnAndAwait('osascript', ['-e', appleScript]) + } + + const terminal = await findLinuxTerminal() + + if (!terminal) { + return 'error' + } + + return spawnAndAwait(terminal.name, terminal.buildArgs(shellCommand)) +} + +/** + * @param {string[]} parts + * @returns {string} + */ +function quoteForShell(parts) { + return parts.map(part => `'${part.replaceAll("'", "'\\''")}'`).join(' ') +} + +/** + * @param {string[]} parts + * @returns {string} + */ +function quoteForShellDisplay(parts) { + return parts.map(part => part.includes(' ') ? `'${part}'` : part).join(' ') +} + +/** + * @param {string} command + * @returns {string} + */ +function quoteForAppleScript(command) { + return `"${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` +} + +/** + * @param {string} command + * @param {string[]} args + * @param {import('node:child_process').SpawnOptionsWithoutStdio} [extraOptions] + * @returns {Promise} + */ +function spawnAndAwait(command, args, extraOptions) { + return new Promise((resolve) => { + const child = spawn(command, args, { detached: true, stdio: 'ignore', ...extraOptions }) + + child.once('error', () => { + resolve('error') + }) + + child.once('spawn', () => { + child.unref() + resolve('ok') + }) + }) +} diff --git a/src/main/index.js b/src/main/index.js index 5ca4678343539..2551932bda187 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -28,6 +28,7 @@ import contextMenu from 'electron-context-menu' import packageDetails from '../../package.json' import { handleOpenInExternalPlayer } from './externalPlayer' +import { getExecutableVersions, handleDownloadVideo, resolveExecutable } from './download' import { generatePoToken } from './poTokenGenerator' import { isFreeTubeUrl } from './utils' @@ -1433,6 +1434,93 @@ function runApp() { return result.filePaths[0] } + /** + * @param {string} settingId + * @param {string} value + */ + async function persistAndSyncSetting(settingId, value) { + await baseHandlers.settings.upsert(settingId, value) + + const syncPayload = { + event: SyncEvents.GENERAL.UPSERT, + data: { + _id: settingId, + value + } + } + + BrowserWindow.getAllWindows().forEach((window) => { + if (isFreeTubeUrl(window.webContents.getURL())) { + window.webContents.send(IpcChannels.SYNC_SETTINGS, syncPayload) + } + }) + } + + ipcMain.handle(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY, async (event) => { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return null + } + + const currentPath = (await baseHandlers.settings._findOne('ytdlpOutputDirectory'))?.value + + const dialogOptions = { + defaultPath: typeof currentPath === 'string' && currentPath.length > 0 ? currentPath : app.getPath('downloads'), + properties: ['openDirectory'] + } + + const window = BrowserWindow.fromWebContents(event.sender) + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled) { + return null + } + + await persistAndSyncSetting('ytdlpOutputDirectory', result.filePaths[0]) + return result.filePaths[0] + }) + + /** + * @param {import('electron').IpcMainInvokeEvent} event + * @param {string} settingId + * @returns {Promise} + */ + async function chooseExecutable(event, settingId) { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return null + } + + const currentPath = (await baseHandlers.settings._findOne(settingId))?.value + + const dialogOptions = { + defaultPath: typeof currentPath === 'string' && currentPath.length > 0 ? currentPath : undefined, + properties: ['openFile'], + ...(process.platform === 'win32' && { + filters: [ + { name: 'Executables', extensions: ['exe'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + } + + const window = BrowserWindow.fromWebContents(event.sender) + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled) { + return null + } + + await persistAndSyncSetting(settingId, result.filePaths[0]) + return result.filePaths[0] + } + + ipcMain.handle(IpcChannels.CHOOSE_YTDLP_EXECUTABLE, async (event) => { + return chooseExecutable(event, 'ytdlpExecutable') + }) + ipcMain.on(IpcChannels.CHOOSE_DEFAULT_FOLDER, async (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return @@ -1572,6 +1660,38 @@ function runApp() { ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, handleOpenInExternalPlayer) + ipcMain.handle(IpcChannels.DOWNLOAD_VIDEO, handleDownloadVideo) + + ipcMain.handle(IpcChannels.FIND_EXECUTABLE_ON_PATH, async (event, name, settingId) => { + if ( + !isFreeTubeUrl(event.senderFrame.url) || + typeof name !== 'string' || !/^[\w-]+$/.test(name) || + settingId !== 'ytdlpExecutable' + ) { + return null + } + + const currentPath = (await baseHandlers.settings._findOne(settingId))?.value || '' + + const resolvedPath = await resolveExecutable(name, currentPath) + + if (resolvedPath && resolvedPath !== currentPath) { + await persistAndSyncSetting(settingId, resolvedPath) + } + + return resolvedPath + }) + + ipcMain.handle(IpcChannels.GET_DOWNLOADER_EXECUTABLE_VERSIONS, async (event) => { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return { ytdlp: null } + } + + const ytdlpExecutable = (await baseHandlers.settings._findOne('ytdlpExecutable'))?.value || '' + + return getExecutableVersions(ytdlpExecutable) + }) + ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { return replaceHttpCache diff --git a/src/preload/interface.js b/src/preload/interface.js index b32286c8c07c5..0149f05141083 100644 --- a/src/preload/interface.js +++ b/src/preload/interface.js @@ -131,6 +131,36 @@ export default { ipcRenderer.send(IpcChannels.CHOOSE_DEFAULT_FOLDER) }, + /** + * @returns {Promise} + */ + chooseYtdlpOutputDirectory: () => { + return ipcRenderer.invoke(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY) + }, + + /** + * @returns {Promise} + */ + chooseYtdlpExecutable: () => { + return ipcRenderer.invoke(IpcChannels.CHOOSE_YTDLP_EXECUTABLE) + }, + + /** + * @param {string} name + * @param {'ytdlpExecutable'} settingId + * @returns {Promise} + */ + resolveExecutablePath: (name, settingId) => { + return ipcRenderer.invoke(IpcChannels.FIND_EXECUTABLE_ON_PATH, name, settingId) + }, + + /** + * @returns {Promise<{ ytdlp: string | null }>} + */ + getDownloaderExecutableVersions: () => { + return ipcRenderer.invoke(IpcChannels.GET_DOWNLOADER_EXECUTABLE_VERSIONS) + }, + /** * @param {string} filename * @param {ArrayBuffer} contents @@ -168,6 +198,22 @@ export default { }) }, + /** + * @param {string} videoId + * @param {'video' | 'audio'} mode + * @param {number | null} [startTime] + * @param {number | null} [endTime] + * @returns {Promise} + */ + downloadVideo: (videoId, mode, startTime, endTime) => { + // require the user to have interacted with the page recently + if (navigator.userActivation.isActive) { + return ipcRenderer.invoke(IpcChannels.DOWNLOAD_VIDEO, { videoId, mode, startTime, endTime }) + } + + return Promise.resolve('invalid') + }, + /** * @param {number} factor */ diff --git a/src/renderer/components/ExternalDownloaderSettings.vue b/src/renderer/components/ExternalDownloaderSettings.vue new file mode 100644 index 0000000000000..1734bc03aab82 --- /dev/null +++ b/src/renderer/components/ExternalDownloaderSettings.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css index 61b26e46c2ef7..03018e9844cb5 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css @@ -139,3 +139,31 @@ inset-inline: 0 auto; } } + +.downloadOptions { + padding: 12px; + padding-block-end: 0; + max-inline-size: min-content; + min-inline-size: 150px; + margin-inline: auto; +} + +.downloadOptions :deep(.ft-input) { + background-color: var(--bg-color); + border: 1px solid var(--tertiary-text-color); +} + +.downloadButtons { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + max-inline-size: min-content; + min-inline-size: 150px; + margin-inline: auto; +} + +.downloadButtons .action { + padding: 6px; + white-space: initial; +} diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue index 3a2b430bc2f13..f33d5d171e119 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue @@ -111,6 +111,56 @@ theme="secondary" @click="handleExternalPlayer" /> + +
+ + + + + + + + + +
+
+ + +
+
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -import { computed, onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' +import FtButton from '../FtButton/FtButton.vue' import FtCard from '../ft-card/ft-card.vue' +import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtIconButton from '../FtIconButton/FtIconButton.vue' +import FtInput from '../FtInput/FtInput.vue' import FtShareButton from '../FtShareButton/FtShareButton.vue' import FtSubscribeButton from '../FtSubscribeButton/FtSubscribeButton.vue' +import FtToggleSwitch from '../FtToggleSwitch/FtToggleSwitch.vue' import store from '../../store' -import { formatNumber, showToast } from '../../helpers/utils' +import { formatDurationAsTimestamp, formatNumber, showToast } from '../../helpers/utils' const props = defineProps({ id: { @@ -246,6 +301,7 @@ const emit = defineEmits([ const USING_ELECTRON = process.env.IS_ELECTRON const { locale, t } = useI18n() +const router = useRouter() /** @type {import('vue').ComputedRef} */ const hideSharingActions = computed(() => store.getters.getHideSharingActions) @@ -333,6 +389,9 @@ const historyEntryExists = computed(() => store.getters.getHistoryCacheById[prop /** @type {import('vue').ComputedRef} */ const externalPlayer = computed(() => store.getters.getExternalPlayer) +/** @type {import('vue').ComputedRef} */ +const downloadEnabled = computed(() => store.getters.getYtdlpDownloadEnabled) + /** @type {import('vue').ComputedRef} */ const defaultPlayback = computed(() => store.getters.getDefaultPlayback) @@ -392,6 +451,86 @@ function handleExternalPlayer() { } } +const downloadIncludeTimestamp = ref(false) +const downloadStartTime = ref('0:00') +const downloadEndTime = ref('') + +function updateDownloadIncludeTimestamp() { + downloadIncludeTimestamp.value = !downloadIncludeTimestamp.value + + if (downloadIncludeTimestamp.value) { + downloadStartTime.value = formatDurationAsTimestamp(Math.trunc(props.getTimestamp())) + downloadEndTime.value = formatDurationAsTimestamp(Math.trunc(props.lengthSeconds)) + } +} + +/** + * @param {string} value + */ +function updateDownloadStartTime(value) { + downloadStartTime.value = value +} + +/** + * @param {string} value + */ +function updateDownloadEndTime(value) { + downloadEndTime.value = value +} + +/** + * @param {string} value + * @returns {number | null} + */ +function parseTimestampToSeconds(value) { + const trimmed = value.trim() + if (trimmed === '') { + return null + } + + const parts = trimmed.split(':') + if (parts.length < 2 || parts.length > 3 || parts.some(part => !/^\d+$/.test(part))) { + return null + } + + const numbers = parts.map(Number) + const [hours, minutes, seconds] = numbers.length === 3 ? numbers : [0, ...numbers] + + return (hours * 3600) + (minutes * 60) + seconds +} + +/** + * @param {'video' | 'audio'} mode + */ +async function handleDownload(mode) { + if (!process.env.IS_ELECTRON) { + return + } + + const startTime = downloadIncludeTimestamp.value ? parseTimestampToSeconds(downloadStartTime.value) : null + const endTime = downloadIncludeTimestamp.value ? parseTimestampToSeconds(downloadEndTime.value) : null + + const result = await window.ftElectron.downloadVideo(props.id, mode, startTime, endTime) + + switch (result) { + case 'ok': + showToast(mode === 'audio' + ? t('Video.Audio download has started') + : t('Video.Video download has started')) + break + case 'cancelled': + // user closed the folder picker, nothing to report + break + case 'disabled': + case 'not-configured': + case 'error': + showToast(t('Video.Download failed - Click to open External Downloader settings'), 10000, () => { + router.push({ path: '/settings', query: { section: 'external-downloader' } }) + }) + break + } +} + onMounted(() => { if (process.env.IS_ELECTRON || 'mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ diff --git a/src/renderer/main.js b/src/renderer/main.js index 763d6a0875973..b16d3538c5617 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -61,12 +61,14 @@ import { faFilterCircleXmark, faFlask, faFire, + faFolderOpen, faForward, faGamepad, faGauge, faGlobe, faGrip, faHashtag, + faHeadphones, faHeart, faHistory, faImages, @@ -186,12 +188,14 @@ library.add( faFilterCircleXmark, faFlask, faFire, + faFolderOpen, faForward, faGamepad, faGauge, faGlobe, faGrip, faHashtag, + faHeadphones, faHeart, faHistory, faImages, diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index 694be8ff0be32..540f5b47497ea 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -189,6 +189,12 @@ const state = { externalPlayerIgnoreDefaultArgs: false, externalPlayerCustomArgs: '[]', showAddedExternalPlayerCustomArgs: true, + ytdlpDownloadEnabled: false, + ytdlpDownloadMode: 'prompt_folder', + ytdlpExecutable: '', + ytdlpOutputDirectory: '', + ytdlpVideoCustomArgs: '', + ytdlpAudioCustomArgs: '', expandSideBar: false, hideActiveSubscriptions: false, hideChannelCommunity: false, @@ -440,6 +446,11 @@ export const NON_TRANSFERABLE_SETTINGS = new Set([ 'externalPlayerIgnoreDefaultArgs', 'externalPlayerCustomArgs', 'showAddedExternalPlayerCustomArgs', + // ExternalDownloaderSettings + 'ytdlpExecutable', + 'ytdlpOutputDirectory', + 'ytdlpVideoCustomArgs', + 'ytdlpAudioCustomArgs', // Others 'disableSmoothScrolling', 'hideToTrayOnMinimize', diff --git a/src/renderer/views/Settings/Settings.vue b/src/renderer/views/Settings/Settings.vue index 927361de33235..f2a3331bd3982 100644 --- a/src/renderer/views/Settings/Settings.vue +++ b/src/renderer/views/Settings/Settings.vue @@ -61,13 +61,15 @@