├── .editorconfig ├── .env.sample ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── biome.json ├── build ├── entitlements.mac.plist ├── icon.icns ├── icon.ico ├── icon.svg ├── icon_1024x1024.png ├── icon_128x128.png ├── icon_16x16.png ├── icon_196x196.png ├── icon_24x24.png ├── icon_256x256.png ├── icon_32x32.png ├── icon_48x48.png ├── icon_512x512.png └── icon_64x64.png ├── components.json ├── dev-app-update.yml ├── drizzle.config.ts ├── drizzle ├── 0000_furry_katie_power.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── electron-builder.yml ├── electron.vite.config.ts ├── git-config.cjs ├── lefthook.yml ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── main │ ├── api.ts │ ├── env.d.ts │ ├── global.d.ts │ ├── index.ts │ ├── lib │ │ ├── bin.utils.ts │ │ ├── clipboardMonitor.ts │ │ ├── platform.ts │ │ ├── soundTypes.ts │ │ ├── windowUtils.ts │ │ └── ytdlp-wrapper │ │ │ └── index.ts │ ├── secureStore │ │ └── index.ts │ ├── stores │ │ ├── AppStore.ts │ │ ├── app-database.helpers.ts │ │ ├── app-database.schema.ts │ │ ├── app-database.ts │ │ ├── app.migrations.ts │ │ └── app.store.ts │ ├── trpc.ts │ ├── trpc │ │ ├── dialog.api.ts │ │ ├── dialog.schema.ts │ │ ├── dialog.utils.ts │ │ ├── events.api.ts │ │ ├── events.ee.ts │ │ ├── events.types.ts │ │ ├── internal.api.ts │ │ ├── response.ts │ │ ├── settings.api.ts │ │ ├── trpc.ts │ │ ├── window.api.ts │ │ ├── ytdlp.api.ts │ │ ├── ytdlp.core.ts │ │ ├── ytdlp.ee.ts │ │ └── ytdlp.utils.ts │ └── updater │ │ └── index.ts ├── preload │ ├── index.d.ts │ └── index.ts ├── renderer │ ├── index.html │ └── src │ │ ├── assets │ │ ├── electron.svg │ │ ├── frame-font.woff2 │ │ ├── main.scss │ │ └── wavy-lines.svg │ │ ├── components │ │ ├── app │ │ │ ├── background-lines │ │ │ │ └── index.tsx │ │ │ ├── health-card │ │ │ │ └── index.tsx │ │ │ ├── logo │ │ │ │ ├── Image.tsx │ │ │ │ └── index.tsx │ │ │ ├── percent-chart-card │ │ │ │ └── index.tsx │ │ │ ├── settings-dialog │ │ │ │ └── index.tsx │ │ │ ├── theme-provider │ │ │ │ └── index.tsx │ │ │ └── theme-toggle │ │ │ │ └── index.tsx │ │ ├── baseLayout.tsx │ │ └── ui │ │ │ ├── ButtonLoading.tsx │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── app-navbar.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox-button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── clickable-text.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dialog-credenza.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── field.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-password.tsx │ │ │ ├── input.tsx │ │ │ ├── kbd.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── mock-browser.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── pill-filter-control.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress-circle.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── responsive-tabs.tsx │ │ │ ├── routes │ │ │ └── animated-content.tsx │ │ │ ├── script-copy-btn.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet-credenza.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── spinner.tsx │ │ │ ├── stepper │ │ │ ├── context.tsx │ │ │ ├── horizontal-step.tsx │ │ │ ├── index.tsx │ │ │ ├── step-button-container.tsx │ │ │ ├── step-icon.tsx │ │ │ ├── step-label.tsx │ │ │ ├── step-next-button.tsx │ │ │ ├── step.tsx │ │ │ ├── types.ts │ │ │ ├── use-media-query.tsx │ │ │ ├── use-stepper.tsx │ │ │ └── vertical-step.tsx │ │ │ ├── suspense-loader.tsx │ │ │ ├── switch-button.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tag-group.tsx │ │ │ ├── text-rotate.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ ├── tooltip.tsx │ │ │ └── window │ │ │ ├── control-bar.tsx │ │ │ └── control-button.tsx │ │ ├── env.d.ts │ │ ├── hooks │ │ ├── use-callback-ref.ts │ │ ├── use-debounced-callback.ts │ │ ├── use-ipc.ts │ │ ├── use-media-query.ts │ │ ├── use-outside-click.ts │ │ ├── use-promise.ts │ │ ├── use-query-string.ts │ │ └── use-toast.ts │ │ ├── lib │ │ ├── atom.ts │ │ ├── isAsyncFunc.ts │ │ ├── regex.ts │ │ ├── trpc-link.ts │ │ ├── useMediaType.ts │ │ ├── useSettings.ts │ │ ├── useTrpcCache.ts │ │ ├── useWindowState.ts │ │ ├── utils.ts │ │ └── zod-resolver.ts │ │ ├── main.tsx │ │ ├── pages │ │ ├── 404.tsx │ │ ├── _app.tsx │ │ ├── app-guard.tsx │ │ ├── components │ │ │ ├── add-link.store.ts │ │ │ ├── add-link.tsx │ │ │ ├── app-context.tsx │ │ │ ├── file-sheet.tsx │ │ │ ├── group-section.tsx │ │ │ ├── link-list.tsx │ │ │ ├── logs-context.tsx │ │ │ ├── plain-layout.tsx │ │ │ ├── select-download-path.tsx │ │ │ ├── select-download-type.tsx │ │ │ ├── settings-input.tsx │ │ │ ├── settings-toggle.tsx │ │ │ ├── status-bar.tsx │ │ │ ├── ytdl-context.tsx │ │ │ ├── ytdlp-views │ │ │ │ └── advanced-view.tsx │ │ │ └── ytdlp-worker.tsx │ │ ├── index.tsx │ │ └── sections │ │ │ ├── about.tsx │ │ │ ├── cookies.tsx │ │ │ ├── logs.tsx │ │ │ ├── settings.tsx │ │ │ └── ytdlp.tsx │ │ ├── providers.tsx │ │ ├── router.ts │ │ └── routes.tsx └── shared │ ├── base64.ts │ ├── config.ts │ ├── electron │ └── store │ │ ├── createYmlStore.ts │ │ └── inferKey.ts │ ├── encryption │ └── index.ts │ ├── global.d.ts │ ├── json.ts │ ├── logger.ts │ ├── promises │ └── helper.ts │ ├── randomString.ts │ ├── slug │ └── index.ts │ ├── trpc │ ├── error.ts │ └── utils.ts │ ├── worker │ └── core.ts │ └── zod │ └── schema-parser.ts ├── tailwind.config.ts ├── tsconfig.json ├── tsconfig.node.json └── tsconfig.web.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | VITE_GITHUB_REPOSITORY=Venipa/ytdlp-gui 2 | # MAIN_VITE_DEBUG=true 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('eslint').Linter.Config} 3 | * @type {import('@electron-toolkit/eslint-config-ts/eslint-recommended')} 4 | */ 5 | const config = { 6 | extends: [], 7 | rules: { 8 | 'unused-vars': 'off', 9 | } 10 | } 11 | 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 2 | - Venipa 3 | ko_fi: Venipa 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug 2 | description: File a bug report 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Before opening a bug report, please search for the behaviour in the existing issues. 8 | 9 | --- 10 | 11 | Thank you for taking the time to file a bug report. To address this bug as fast as possible, we need some information. 12 | - type: input 13 | id: os 14 | attributes: 15 | label: Operating system 16 | description: | 17 | Which operating system do you use? Please provide the version as well. 18 | Windows: win+r and type `cmd /C "ver | clip"` and paste it here 19 | placeholder: "Microsoft Windows [Version 10.0.19043.1586]" 20 | validations: 21 | required: true 22 | - type: input 23 | id: appVersion 24 | attributes: 25 | label: ytdlp-gui Version 26 | description: | 27 | What App Version are you running on, you can find your version in the top left or in the tray menu from ytdlp-gui. 28 | placeholder: "v0.5.4" 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: bug-description 33 | attributes: 34 | label: Bug description 35 | description: What happened? 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: steps 40 | attributes: 41 | label: Steps to reproduce 42 | description: Which steps do we need to take to reproduce this error? 43 | - type: textarea 44 | id: logs 45 | attributes: 46 | label: Relevant log output 47 | description: | 48 | If applicable, provide relevant log output. No need for backticks here. 49 | Windows: win+r and type "%appdata%\..\Local\Programs\ytdlp-gui\" and search for the latest error---.log file (example: error-2022-03-26.log) 50 | 51 | make sure to check contents to avoid sensitive data. (if any) 52 | render: shell 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dist* 4 | out 5 | .DS_Store 6 | *.log* 7 | /.env* 8 | !/.env.sample 9 | /git.json 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | electron_mirror=https://npmmirror.com/mirrors/electron/ 2 | electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ 3 | shamefully-hoist=true 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "biomejs.biome" 4 | }, 5 | "[typescriptreact]": { 6 | "editor.defaultFormatter": "biomejs.biome" 7 | }, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "biomejs.biome" 10 | }, 11 | "[json]": { 12 | "editor.defaultFormatter": "biomejs.biome" 13 | }, 14 | "files.watcherExclude": { 15 | "**/.git/*/**": true, 16 | "**/.git/objects/**": true, 17 | "**/.git/subtree-cache/**": true, 18 | "**/app-builds": true, 19 | "**/dist": true, 20 | "**/dist/**": true, 21 | "**/out": true, 22 | "**/node_modules/*/**": true, 23 | "**/release": true 24 | }, 25 | "tailwindCSS.files.exclude": [ 26 | "**/.git/**", 27 | "**/node_modules/**", 28 | "**/dist/**", 29 | "**/.hg/**", 30 | "**/.svn/**" 31 | ], 32 | "discord.enabled": false 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

ytdlp-gui

3 |

A yt-dlp gui app

4 |

5 | 6 |

7 | 8 | 9 | 10 | ## Project Setup 11 | 12 | ### Install 13 | 14 | ```bash 15 | $ pnpm install 16 | ``` 17 | 18 | ### Development 19 | 20 | ```bash 21 | $ pnpm dev 22 | ``` 23 | 24 | ### Build 25 | 26 | ```bash 27 | # For windows 28 | $ pnpm build:win 29 | 30 | # For macOS 31 | $ pnpm build:mac 32 | 33 | # For Linux 34 | $ pnpm build:linux 35 | ``` 36 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "formatter": { 7 | "indentStyle": "tab", 8 | "formatWithErrors": true, 9 | "indentWidth": 4, 10 | "lineWidth": 180, 11 | "attributePosition": "auto" 12 | }, 13 | "javascript": { 14 | "formatter": { 15 | "jsxQuoteStyle": "single", 16 | "quoteStyle": "double", 17 | "bracketSameLine": true 18 | }, 19 | "parser": { 20 | "unsafeParameterDecoratorsEnabled": true 21 | } 22 | }, 23 | "linter": { 24 | "enabled": true, 25 | "rules": { 26 | "recommended": false 27 | }, 28 | "ignore": [ 29 | "node_modules" 30 | ] 31 | } 32 | } -------------------------------------------------------------------------------- /build/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.files.all 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon.icns -------------------------------------------------------------------------------- /build/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon.ico -------------------------------------------------------------------------------- /build/icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_1024x1024.png -------------------------------------------------------------------------------- /build/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_128x128.png -------------------------------------------------------------------------------- /build/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_16x16.png -------------------------------------------------------------------------------- /build/icon_196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_196x196.png -------------------------------------------------------------------------------- /build/icon_24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_24x24.png -------------------------------------------------------------------------------- /build/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_256x256.png -------------------------------------------------------------------------------- /build/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_32x32.png -------------------------------------------------------------------------------- /build/icon_48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_48x48.png -------------------------------------------------------------------------------- /build/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_512x512.png -------------------------------------------------------------------------------- /build/icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/build/icon_64x64.png -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/renderer/src/assets/main.scss", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@renderer/components", 15 | "utils": "@renderer/lib/utils", 16 | "ui": "@renderer/components/ui", 17 | "lib": "@renderer/lib", 18 | "hooks": "@renderer/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: github 2 | url: https://github.com/Venipa/ytdlp-deskop 3 | updaterCacheDirName: ytdlpd-updater 4 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit' 2 | 3 | export default { 4 | schema: './src/main/stores/app-database.schema.ts', 5 | out: './drizzle', 6 | dialect: 'sqlite' 7 | } satisfies Config 8 | -------------------------------------------------------------------------------- /drizzle/0000_furry_katie_power.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `downloads` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `title` text NOT NULL, 4 | `state` text, 5 | `filepath` text NOT NULL, 6 | `filesize` integer, 7 | `type` text, 8 | `url` text NOT NULL, 9 | `source` text NOT NULL, 10 | `retryCount` integer, 11 | `error` blob DEFAULT 'null', 12 | `meta` blob DEFAULT 'null', 13 | `metaId` text NOT NULL, 14 | `created` text DEFAULT (current_timestamp) NOT NULL 15 | ); 16 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1739889239847, 9 | "tag": "0000_furry_katie_power", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/electron-builder.json 2 | 3 | appId: net.venipa.ytdlpgui 4 | productName: ytdlp-gui 5 | extraMetadata: 6 | name: YTDLP GUI 7 | description: "YTDLP GUI App" 8 | directories: 9 | buildResources: build 10 | files: 11 | - "!**/.vscode/*" 12 | - "!**/{dist_electron,.github,screenshots}/*" 13 | - "!{dist_electron,.github,screenshots}/*" 14 | - "!.*/*" 15 | - "!src/*" 16 | - "!electron.vite.config.{js,ts,mjs,cjs}" 17 | - "!{.*rc,*.config.js,*.config.ts,*config.json,.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}" 18 | - "!{.env,.env.*,.npmrc,pnpm-lock.yaml}" 19 | - "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json,tsconfig*.json}" 20 | asarUnpack: 21 | - resources/** 22 | - build/*.ico 23 | - build/*.png 24 | - build/*.jpg 25 | win: 26 | executableName: ytdlp-gui 27 | artifactName: ytdlp-gui-${version}-setup.${ext} 28 | compression: maximum 29 | target: 30 | - target: nsis 31 | arch: x64 32 | icon: build/icon.ico 33 | nsis: 34 | shortcutName: ${productName} 35 | uninstallDisplayName: ${productName} 36 | createDesktopShortcut: always 37 | installerIcon: build/icon.ico 38 | installerHeaderIcon: build/icon.ico 39 | deleteAppDataOnUninstall: true 40 | 41 | mac: 42 | entitlementsInherit: build/entitlements.mac.plist 43 | extendInfo: 44 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 45 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 46 | notarize: false 47 | target: 48 | - target: dmg 49 | arch: arm64 50 | icon: build/icon.icns 51 | dmg: 52 | title: "Install or Update ${productName} ${version}" 53 | backgroundColor: "#101010" 54 | # linux: 55 | # target: 56 | # - target: appImage 57 | # arch: x64 58 | # - target: appImage 59 | # arch: arm64 60 | # - target: deb 61 | # arch: x64 62 | # maintainer: ytdlpd.venipa.net 63 | # category: Music 64 | # icon: build/icon.icns 65 | # appx: 66 | # electronUpdaterAware: true 67 | # addAutoLaunchExtension: true 68 | 69 | npmRebuild: false 70 | publish: 71 | - provider: github 72 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import generouted from '@generouted/react-router/plugin' 2 | import ViteYaml from '@modyfi/vite-plugin-yaml' 3 | import react from '@vitejs/plugin-react' 4 | import { bytecodePlugin, defineConfig, externalizeDepsPlugin } from 'electron-vite' 5 | import { merge } from 'lodash-es' 6 | import { resolve } from 'path' 7 | import { AliasOptions, ResolveOptions } from 'vite' 8 | console.log('current working dir:', resolve('.')) 9 | const resolveOptions: { resolve: ResolveOptions & { alias: AliasOptions } } = { 10 | resolve: { 11 | alias: { 12 | '@renderer': resolve('src/renderer/src'), 13 | '@main': resolve('src/main'), 14 | '@preload': resolve('src/preload'), 15 | '@shared': resolve('src/shared'), 16 | '@': resolve('src'), 17 | '~': resolve('.') 18 | } 19 | } 20 | } 21 | const externalizedEsmDeps = [ 22 | 'lodash-es', 23 | '@faker-js/faker', 24 | '@trpc-limiter/memory', 25 | 'got', 26 | 'encryption.js', 27 | 'filenamify', 28 | 'yt-dlp-wrap', 29 | 'p-queue' 30 | ] 31 | const isMac = !!process.env.ACTION_RUNNER?.startsWith('macos-') 32 | const isProduction = process.env.NODE_ENV === "production"; 33 | export default defineConfig({ 34 | main: { 35 | ...resolveOptions, 36 | plugins: [ 37 | externalizeDepsPlugin({ exclude: [...externalizedEsmDeps] }), 38 | ViteYaml(), 39 | ...((!isMac && isProduction && [bytecodePlugin({ transformArrowFunctions: false })]) || []) 40 | ], 41 | build: { 42 | rollupOptions: { 43 | output: { 44 | manualChunks: (id: string): any => { 45 | if (externalizedEsmDeps.find((d) => d === id)) return id 46 | } 47 | } 48 | } 49 | }, 50 | publicDir: './resources' 51 | }, 52 | preload: { 53 | ...resolveOptions, 54 | plugins: [ 55 | externalizeDepsPlugin({ exclude: [...externalizedEsmDeps] }), 56 | ViteYaml(), 57 | ...((!isMac && isProduction && [bytecodePlugin({ transformArrowFunctions: false })]) || []) 58 | ] 59 | }, 60 | renderer: { 61 | ...merge(resolveOptions, { 62 | resolve: { 63 | alias: { 64 | '@': resolve('src/renderer/src') 65 | } 66 | } 67 | }), 68 | plugins: [ 69 | ViteYaml(), 70 | react({ 71 | babel: { 72 | plugins: [ 73 | [ 74 | 'styled-jsx/babel', 75 | { 76 | plugins: [ 77 | [ 78 | 'styled-jsx-plugin-postcss', 79 | { 80 | path: './postcss.config.js', 81 | compileEnv: 'worker' 82 | } 83 | ] 84 | ] 85 | } 86 | ] 87 | ] 88 | } 89 | }), 90 | generouted({ 91 | source: { 92 | routes: './src/renderer/src/pages/**/{page,index}.{jsx,tsx}', 93 | modals: './src/renderer/src/pages/**/[+]*.{jsx,tsx}' 94 | }, 95 | output: resolve('./src/renderer/src/router.ts') 96 | }) 97 | ] 98 | } 99 | }) 100 | -------------------------------------------------------------------------------- /git-config.cjs: -------------------------------------------------------------------------------- 1 | #!/bin/node 2 | 3 | const LCL = require('last-commit-log') 4 | const { resolve } = require('path') 5 | const { writeFileSync } = require('fs') 6 | console.log("current working dir:", resolve(".")) 7 | const lcl = new LCL() 8 | try { 9 | const lastCommit = lcl.getLastCommitSync() 10 | if (!lastCommit) return 11 | writeFileSync(resolve(__dirname, 'git.json'), JSON.stringify(lastCommit)) 12 | } catch (e) {} 13 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | lint: 5 | glob: "src/**/*.{js,jsx,ts,tsx,json,yaml,yml,html}" 6 | run: npx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files} 7 | stage_fixed: true 8 | # tests: 9 | # run: npm run test 10 | # include: "./src/**/*.{js,jsx,ts,tsx,html}" 11 | # exclude: "node_modules" 12 | 13 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/main/api.ts: -------------------------------------------------------------------------------- 1 | import { router } from "@main/trpc/trpc"; 2 | import { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 3 | import { dialogRouter } from "./trpc/dialog.api"; 4 | import { eventsRouter } from "./trpc/events.api"; 5 | import { internalRouter } from "./trpc/internal.api"; 6 | import { settingsRouter } from "./trpc/settings.api"; 7 | import { windowRouter } from "./trpc/window.api"; 8 | import { ytdlpRouter } from "./trpc/ytdlp.api"; 9 | 10 | export const appRouter = router({ 11 | window: windowRouter, 12 | dialog: dialogRouter, 13 | internals: internalRouter, 14 | ytdl: ytdlpRouter, 15 | settings: settingsRouter, 16 | events: eventsRouter, 17 | }); 18 | 19 | export type AppRouter = typeof appRouter; 20 | 21 | export type RouterInput = inferRouterInputs; 22 | export type RouterOutput = inferRouterOutputs; 23 | -------------------------------------------------------------------------------- /src/main/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly MAIN_VITE_ANYSTACK_API_KEY: string; 5 | readonly MAIN_VITE_ANYSTACK_PRODUCT_ID: string; 6 | readonly NODE_ENV: "production" | "development"; 7 | } 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | declare const Anystack; 3 | } 4 | -------------------------------------------------------------------------------- /src/main/lib/bin.utils.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import { statSync } from "node:fs"; 3 | import platform from "./platform"; 4 | 5 | const shell = (cmd: string) => execSync(cmd, { encoding: "utf8" }); 6 | 7 | export function executableIsAvailable(name: string) { 8 | if (platform.isWindows) return null; 9 | try { 10 | const execPath = shell(`which ${name}`); 11 | return (statSync(execPath) && execPath) || null; 12 | } catch (error) { 13 | return null; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/lib/clipboardMonitor.ts: -------------------------------------------------------------------------------- 1 | import clip from "clipboard-event"; 2 | import { clipboard } from "electron"; 3 | const HTTPS = /^https/gi; 4 | export class ClipboardMonitor { 5 | constructor(private config: { onHttpsText?: (value: string) => void; distinct?: boolean }) { 6 | clip.startListening(); 7 | } 8 | start() { 9 | clip.on("change", this.checkClipboard.bind(this)); 10 | } 11 | stop() { 12 | clip.off("change", this.checkClipboard.bind(this)); 13 | } 14 | private _lastText: string | null = null; 15 | private checkClipboard() { 16 | console.log("clipboard changed"); 17 | const text = clipboard.readText("clipboard"); 18 | if (text) { 19 | if (HTTPS.test(text) && (!this.config.distinct || !this._lastText || this._lastText !== text)) { 20 | this.config.onHttpsText?.(text.replace(HTTPS, "https")); 21 | if (this.config.distinct) this._lastText = text; 22 | } 23 | } 24 | } 25 | destroy() { 26 | clip.stopListening(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/lib/platform.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | isWindows: process.platform === "win32", 3 | isMacOS: process.platform === "darwin", 4 | isLinux: process.platform === "linux", 5 | }; 6 | -------------------------------------------------------------------------------- /src/main/lib/soundTypes.ts: -------------------------------------------------------------------------------- 1 | export interface SoundSource { 2 | __modulePath: string; 3 | key: string; 4 | caption: string; 5 | press: { 6 | SPACE: string; 7 | ENTER: string; 8 | BACKSPACE: string; 9 | GENERICR0: string; 10 | GENERICR1: string; 11 | GENERICR2: string; 12 | GENERICR3: string; 13 | GENERICR4: string; 14 | [key: string]: string; 15 | }; 16 | release: { 17 | SPACE: string; 18 | ENTER: string; 19 | BACKSPACE: string; 20 | GENERIC: string; 21 | [key: string]: string; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/main/lib/windowUtils.ts: -------------------------------------------------------------------------------- 1 | import { createYmlStore } from "@shared/electron/store/createYmlStore"; 2 | import { BrowserWindow, screen } from "electron"; 3 | 4 | export async function wrapWindowHandler(win: BrowserWindow, windowName: string, { width: defaultWidth, height: defaultHeight }: { width: number; height: number }) { 5 | const key = "window-state"; 6 | const name = `window-state-${windowName}`; 7 | const store = createYmlStore(name); 8 | const defaultSize = { 9 | width: defaultWidth, 10 | height: defaultHeight, 11 | }; 12 | let state: { width: number; height: number; x: number; y: number; maximized?: boolean } | null = null; 13 | const restore = () => store.get(key, defaultSize); 14 | 15 | const getCurrentPosition = () => { 16 | const [x, y] = win.getPosition(); 17 | const [width, height] = win.getSize(); 18 | return { 19 | x, 20 | y, 21 | width, 22 | height, 23 | maximized: win.isMaximized(), 24 | }; 25 | }; 26 | 27 | const windowWithinBounds = (windowState, bounds) => { 28 | return ( 29 | windowState.x >= bounds.x && 30 | windowState.y >= bounds.y && 31 | windowState.x + windowState.width <= bounds.x + bounds.width && 32 | windowState.y + windowState.height <= bounds.y + bounds.height 33 | ); 34 | }; 35 | 36 | const resetToDefaults = () => { 37 | const bounds = screen.getPrimaryDisplay().bounds; 38 | return Object.assign({}, defaultSize, { 39 | x: (bounds.width - defaultSize.width) / 2, 40 | y: (bounds.height - defaultSize.height) / 2, 41 | }); 42 | }; 43 | 44 | const ensureVisibleOnSomeDisplay = (windowState) => { 45 | const visible = screen.getAllDisplays().some((display) => { 46 | return windowWithinBounds(windowState, display.bounds); 47 | }); 48 | if (!visible) { 49 | // Window is partially or fully not visible now. 50 | // Reset it to safe defaults. 51 | return resetToDefaults(); 52 | } 53 | return windowState; 54 | }; 55 | const saveState = () => { 56 | if (!win.isMinimized() && !win.isMaximized()) { 57 | state = Object.assign({}, state, getCurrentPosition()); 58 | } 59 | store.set(key, state); 60 | }; 61 | state = ensureVisibleOnSomeDisplay(restore()); 62 | win.on("close", saveState); 63 | return { state, saveState }; 64 | } 65 | -------------------------------------------------------------------------------- /src/main/secureStore/index.ts: -------------------------------------------------------------------------------- 1 | import { createEncryptedStore } from "@shared/electron/store/createYmlStore"; 2 | type Credential = { 3 | account: string; 4 | password: string; 5 | }; 6 | type Credentials = Array; 7 | type CredentialStore = { 8 | credentials: Record; 9 | }; 10 | const store = createEncryptedStore("app", { 11 | defaults: { 12 | credentials: {}, 13 | }, 14 | }); 15 | class SecureStore { 16 | getAll() { 17 | return new Promise((resolve, reject) => 18 | resolve( 19 | Object.entries(store.get("credentials", {})).map( 20 | ([account, password]) => 21 | ({ 22 | account, 23 | password, 24 | }) as Credential, 25 | ), 26 | ), 27 | ); 28 | } 29 | set(key: string, value: any) { 30 | return new Promise(async (resolve, reject) => { 31 | store.set(`credentials.${key}`, value); 32 | return resolve(value); 33 | }); 34 | } 35 | get(key: string) { 36 | return new Promise(async (resolve, reject) => { 37 | const value = store.get(`credentials.${key}`, null); 38 | return resolve(value); 39 | }); 40 | } 41 | delete(key: string) { 42 | return new Promise(async (resolve, reject) => { 43 | store.delete(`credentials.${key}`); 44 | return resolve(true); 45 | }); 46 | } 47 | readonly setPassword: typeof this.set = this.set.bind(this); 48 | readonly getPassword: typeof this.get = this.get.bind(this); 49 | get instance() { 50 | return store; 51 | } 52 | } 53 | 54 | const secureStore = new SecureStore(); 55 | export default secureStore; 56 | -------------------------------------------------------------------------------- /src/main/stores/AppStore.ts: -------------------------------------------------------------------------------- 1 | export interface AppStore { 2 | ytdlp: { 3 | path: string; 4 | version: string; 5 | checkForUpdate: boolean; 6 | useGlobal: boolean; 7 | flags: { 8 | nomtime: boolean; 9 | custom: string; 10 | }; 11 | }; 12 | download: { paths: string[]; selected: string }; 13 | features: { 14 | clipboardMonitor: boolean; 15 | clipboardMonitorAutoAdd: boolean; 16 | concurrentDownloads: number; 17 | advancedView: boolean; 18 | }; 19 | startMinimized: boolean; 20 | startOnBoot: boolean; 21 | beta: boolean; 22 | } 23 | -------------------------------------------------------------------------------- /src/main/stores/app-database.helpers.ts: -------------------------------------------------------------------------------- 1 | import { and, desc, eq, inArray, InferInsertModel, InferSelectModel, isNotNull, like, not } from "drizzle-orm"; 2 | import { omit } from "lodash"; 3 | import { db } from "./app-database"; 4 | import { downloads } from "./app-database.schema"; 5 | export type SelectDownload = InferSelectModel; 6 | export type InsertDownload = InferInsertModel; 7 | 8 | function createDownload(item: InsertDownload) { 9 | return db.insert(downloads).values(item).returning(); 10 | } 11 | function findDownloadByUrl(url: string, state?: string | string[]) { 12 | const itemState = [state].flat() as string[]; 13 | return db 14 | .select() 15 | .from(downloads) 16 | .where(and(like(downloads.url, `${url}%`), itemState?.length ? inArray(downloads.state, itemState) : isNotNull(downloads.state))) 17 | .all(); 18 | } 19 | function findDownloadByExactUrl(url: string, dbFileId?: SelectDownload["id"]) { 20 | return db 21 | .select() 22 | .from(downloads) 23 | .where(and(eq(downloads.url, url), isNotNull(downloads.meta), ...((dbFileId !== undefined && [not(eq(downloads.id, dbFileId))]) || []))) 24 | .orderBy(desc(downloads.created)) 25 | .get(); 26 | } 27 | function findDownloadById(dbFileId: SelectDownload["id"]) { 28 | return db 29 | .select() 30 | .from(downloads) 31 | .where(and(eq(downloads.id, dbFileId))) 32 | .orderBy(desc(downloads.created)) 33 | .get(); 34 | } 35 | function updateDownload(id: SelectDownload["id"], item: SelectDownload) { 36 | return db 37 | .update(downloads) 38 | .set(omit(item, "id")) 39 | .where(eq(downloads.id, id)) 40 | .returning() 41 | .then(([s]) => s); 42 | } 43 | function deleteDownload(id: SelectDownload["id"]) { 44 | return db.delete(downloads).where(eq(downloads.id, id)); 45 | } 46 | 47 | export const queries = { 48 | downloads: { 49 | createDownload, 50 | updateDownload, 51 | deleteDownload, 52 | findDownloadByUrl, 53 | findDownloadByExactUrl, 54 | findDownloadById, 55 | }, 56 | }; 57 | -------------------------------------------------------------------------------- /src/main/stores/app-database.schema.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { blob, int, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 | import type { VideoInfo } from "yt-dlp-wrap/types"; 4 | 5 | export const downloads = sqliteTable("downloads", { 6 | id: int({ mode: "number" }).primaryKey({ autoIncrement: true }), 7 | title: text().notNull(), 8 | state: text(), 9 | filepath: text().notNull(), 10 | filesize: int({ mode: "number" }), 11 | type: text(), 12 | url: text().notNull(), 13 | source: text().notNull(), 14 | retryCount: int({ mode: "number" }), 15 | error: blob({ mode: "json" }).default(null), 16 | meta: blob({ mode: "json" }).$type().default(null), 17 | metaId: text().notNull(), 18 | created: text().notNull().default(sql`(current_timestamp)`), 19 | }); 20 | -------------------------------------------------------------------------------- /src/main/stores/app-database.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@libsql/client"; 2 | import { drizzle } from "drizzle-orm/libsql"; 3 | import { migrate } from "drizzle-orm/libsql/migrator"; 4 | import { app } from "electron"; 5 | import { mkdirSync } from "fs"; 6 | import path from "path"; 7 | import { pathToFileURL } from "url"; 8 | import * as schema from "./app-database.schema"; 9 | // You can specify any property from the libsql connection options 10 | const dbPath = import.meta.env.DEV ? path.join("out", "sqlite.db") : path.join(app.getPath("userData"), "data.db"); 11 | mkdirSync(path.dirname(dbPath), { recursive: true }); 12 | const database = createClient({ url: pathToFileURL(dbPath).toString() }); 13 | export const runMigrate = async () => { 14 | await migrate(db, { 15 | migrationsFolder: path.join(__dirname, "../../drizzle"), 16 | }); 17 | }; 18 | export const db = drizzle(database, { schema }); 19 | -------------------------------------------------------------------------------- /src/main/stores/app.migrations.ts: -------------------------------------------------------------------------------- 1 | import { MAX_PARALLEL_DOWNLOADS } from "@main/trpc/ytdlp.core"; 2 | import { Migration } from "electron-conf"; 3 | import { AppStore } from "./AppStore"; 4 | 5 | const appStoreMigrations: Migration[] = [ 6 | { 7 | version: 0, 8 | hook(instance, currentVersion) { 9 | instance.store.features.concurrentDownloads = MAX_PARALLEL_DOWNLOADS; 10 | }, 11 | }, 12 | { 13 | version: 1, 14 | hook(instance, currentVersion) { 15 | instance.store.startMinimized = false; 16 | instance.store.startOnBoot = true; 17 | }, 18 | }, 19 | { 20 | version: 2, 21 | hook(instance, currentVersion) { 22 | instance.store.features.advancedView = false; 23 | }, 24 | }, 25 | ]; 26 | 27 | export default appStoreMigrations; 28 | -------------------------------------------------------------------------------- /src/main/stores/app.store.ts: -------------------------------------------------------------------------------- 1 | import { MAX_PARALLEL_DOWNLOADS } from "@main/trpc/ytdlp.core"; 2 | import { createYmlStore } from "@shared/electron/store/createYmlStore"; 3 | import { PathsOf } from "@shared/electron/store/inferKey"; 4 | import { app } from "electron"; 5 | import { AppStore } from "./AppStore"; 6 | import appStoreMigrations from "./app.migrations"; 7 | export interface AppLicense { 8 | code: string; 9 | expires: string; 10 | } 11 | const defaultDownloadsPath = app.getPath("downloads"); 12 | const store = createYmlStore("app-settings", { 13 | migrations: appStoreMigrations, 14 | defaults: { 15 | ytdlp: { 16 | checkForUpdate: true, 17 | useGlobal: false, 18 | flags: { nomtime: true }, 19 | } as AppStore["ytdlp"], 20 | download: { 21 | paths: [defaultDownloadsPath], 22 | selected: defaultDownloadsPath, 23 | }, 24 | features: { 25 | clipboardMonitor: true, 26 | clipboardMonitorAutoAdd: true, 27 | concurrentDownloads: MAX_PARALLEL_DOWNLOADS, 28 | advancedView: false, 29 | }, 30 | startMinimized: false, 31 | startOnBoot: true, 32 | beta: false, 33 | }, 34 | }); 35 | 36 | export type AppStoreKeys = PathsOf; 37 | 38 | export { store as appStore }; 39 | -------------------------------------------------------------------------------- /src/main/trpc.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from "electron"; 2 | import { createIPCHandler } from "electron-trpc/main"; 3 | import { appRouter } from "./api"; 4 | 5 | export const trpcIpcHandler = createIPCHandler({ 6 | router: appRouter, 7 | windows: [], 8 | createContext: ({ event }) => { 9 | return { 10 | window: BrowserWindow.fromWebContents(event.sender), 11 | event, 12 | path: null as any, 13 | } as any; 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/main/trpc/dialog.api.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain } from "electron"; 2 | import { z } from "zod"; 3 | import { createChildWindow, loadUrlOfWindow, lockCenterWithParent, waitForWindowClose } from "./dialog.utils"; 4 | import { publicProcedure, router } from "./trpc"; 5 | 6 | let windowHandles: Record = {}; 7 | const addWindowToHandles = (key: string, win: BrowserWindow) => { 8 | if (!windowHandles[key]) windowHandles[key] = []; 9 | const instance = windowHandles[key].find((w) => !w.isDestroyed()); 10 | if (instance) return instance; 11 | windowHandles[key].push(win); 12 | win.once("closed", () => { 13 | const idx = windowHandles[key].findIndex((d) => d.id === win.id); 14 | windowHandles[key].splice(idx, 1); 15 | }); 16 | return win; 17 | }; 18 | const destroyWindow = (win: BrowserWindow) => { 19 | const entries = Object.values(windowHandles); 20 | const dataIdx = entries.findIndex((d) => d.find((w) => w === win)); 21 | if (dataIdx === -1) return false; 22 | const instanceIdx = entries[dataIdx].findIndex((w) => w === win); 23 | if (instanceIdx === -1) return false; 24 | const [instance] = entries[dataIdx].splice(instanceIdx, 1); 25 | instance.destroy(); 26 | return true; 27 | }; 28 | export const dialogRouter = router({ 29 | settings: publicProcedure.mutation(async ({ ctx: { window, path } }) => { 30 | const currentWindow = addWindowToHandles( 31 | path, 32 | createChildWindow({ 33 | parent: window, 34 | height: 600, 35 | width: 1080, 36 | resizable: false, 37 | maximizable: false, 38 | title: "Settings", 39 | }), 40 | ); 41 | await loadUrlOfWindow(currentWindow, "#/settings") 42 | .then(() => { 43 | lockCenterWithParent(currentWindow); 44 | currentWindow.show(); 45 | }) 46 | .finally(() => { 47 | setTimeout(() => { 48 | ipcMain.emit("windowState"); 49 | }); 50 | }); 51 | await waitForWindowClose(currentWindow); 52 | return { 53 | id: currentWindow.id, 54 | }; 55 | }), 56 | destroyWindow: publicProcedure.input(z.number()).mutation(async ({ input: id }) => { 57 | const win = BrowserWindow.getAllWindows().find((d) => d.id === id); 58 | if (win) return destroyWindow(win); 59 | return false; 60 | }), 61 | }); 62 | -------------------------------------------------------------------------------- /src/main/trpc/dialog.schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const dialogSchema = z 4 | .object({ 5 | closeable: z.boolean().default(true), 6 | closeWithParent: z.boolean().default(false), 7 | title: z.string().nullish(), 8 | }) 9 | .partial() 10 | .default({}); 11 | export type DialogSchema = z.infer; 12 | -------------------------------------------------------------------------------- /src/main/trpc/events.api.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "@trpc/server/observable"; 2 | import { z } from "zod"; 3 | import { eventsEmitter } from "./events.ee"; 4 | import { EventNameSchema } from "./events.types"; 5 | import { publicProcedure, router } from "./trpc"; 6 | 7 | export const eventsRouter = router({ 8 | signal: publicProcedure.input(z.union([z.string(), EventNameSchema])).subscription(({ input: eventName }) => { 9 | return observable((emit) => { 10 | function onStatusChange(data: any) { 11 | emit.next(data); 12 | } 13 | 14 | eventsEmitter.on(eventName, onStatusChange); 15 | 16 | return () => { 17 | eventsEmitter.off(eventName, onStatusChange); 18 | }; 19 | }); 20 | }), 21 | }); 22 | -------------------------------------------------------------------------------- /src/main/trpc/events.ee.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | import { EventNames } from "./events.types"; 3 | 4 | export const eventsEmitter = new EventEmitter(); 5 | export const pushMessageToClient = (...args: any[]) => eventsEmitter.emit("message", ...args); 6 | export const pushChannelToClient = (channel: EventNames, ...args: any[]) => eventsEmitter.emit(channel, ...args); 7 | export type ServerLogType = "info" | "warn" | "error" | "success" | "debug"; 8 | export const pushLogToClient = (message: string, type: ServerLogType = "info", ...args: any[]) => 9 | pushChannelToClient("log", { date: new Date().toISOString(), message, type, args }); 10 | -------------------------------------------------------------------------------- /src/main/trpc/events.types.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const EventNameSchema = z.enum(["message", "log"]); 4 | export type EventNames = z.infer; 5 | -------------------------------------------------------------------------------- /src/main/trpc/internal.api.ts: -------------------------------------------------------------------------------- 1 | import secureStore from "@main/secureStore"; 2 | import { checkForUpdates, setUpdateHandledByFrontend } from "@main/updater"; 3 | import { TRPCError } from "@trpc/server"; 4 | import { shell } from "electron"; 5 | import { autoUpdater } from "electron-updater"; 6 | import { dirname } from "node:path"; 7 | import { z } from "zod"; 8 | import { mainProcedure, publicProcedure, router } from "./trpc"; 9 | import { ytdl } from "./ytdlp.core"; 10 | import { YTDLP_STATE } from "./ytdlp.utils"; 11 | let appInitialized = false; 12 | export const internalRouter = router({ 13 | getAll: mainProcedure.query(() => secureStore.getAll()), 14 | set: mainProcedure 15 | .input( 16 | z.object({ 17 | key: z.string(), 18 | value: z.string(), 19 | }), 20 | ) 21 | .mutation(({ input: { key, value } }) => { 22 | return secureStore.set(key, value); 23 | }), 24 | get: mainProcedure.input(z.string()).query(({ input: key }) => { 25 | return secureStore.get(key); 26 | }), 27 | delete: mainProcedure.input(z.string()).mutation(({ input: key }) => { 28 | return secureStore.delete(key); 29 | }), 30 | setJson: mainProcedure 31 | .input( 32 | z.object({ 33 | key: z.string(), 34 | value: z.union([z.object({}), z.boolean(), z.number(), z.null()]), 35 | }), 36 | ) 37 | .mutation(({ input: { key, value } }) => { 38 | return secureStore.set(key, value); 39 | }), 40 | getJson: mainProcedure.input(z.string()).query(({ input: key }) => { 41 | return secureStore.get(key); 42 | }), 43 | openPath: publicProcedure 44 | .input( 45 | z.object({ 46 | path: z.string(), 47 | openParent: z.boolean().default(false), 48 | }), 49 | ) 50 | .mutation(async ({ input: { path: filePath, openParent } }) => { 51 | if (openParent) shell.openPath(dirname(filePath)); 52 | else shell.showItemInFolder(filePath); 53 | }), 54 | openFile: publicProcedure 55 | .input( 56 | z.object({ 57 | path: z.string(), 58 | }), 59 | ) 60 | .mutation(async ({ input: { path: filePath } }) => { 61 | await shell.openPath(filePath); 62 | }), 63 | checkUpdate: publicProcedure.mutation(async () => { 64 | return await checkForUpdates(); 65 | }), 66 | downloadUpdate: publicProcedure.mutation(() => { 67 | try { 68 | setUpdateHandledByFrontend(true); 69 | return autoUpdater.downloadUpdate().catch((err) => { 70 | setUpdateHandledByFrontend(false); 71 | throw err; 72 | }); 73 | } catch (ex: any) { 74 | throw new TRPCError({ message: ex.message, code: "INTERNAL_SERVER_ERROR" }); 75 | } 76 | }), 77 | quitAndInstallUpdate: publicProcedure.mutation(() => { 78 | try { 79 | return autoUpdater.quitAndInstall(false, true); 80 | } catch (ex: any) { 81 | throw new TRPCError({ message: ex.message, code: "INTERNAL_SERVER_ERROR" }); 82 | } 83 | }), 84 | initializeApp: publicProcedure.mutation(async () => { 85 | if (appInitialized) throw new TRPCError({ message: "App already initialized", code: "INTERNAL_SERVER_ERROR" }); 86 | await ytdl.initialize(); 87 | await ytdl.checkUpdates(ytdl.state === YTDLP_STATE.MISSING_BINARY); 88 | appInitialized = true; 89 | return await ytdl.ytdlp.getVersion(); 90 | }), 91 | } as const); 92 | -------------------------------------------------------------------------------- /src/main/trpc/response.ts: -------------------------------------------------------------------------------- 1 | type Pagination = { 2 | page: number; 3 | perPage: number; 4 | }; 5 | export function withPaginator(input: Pagination) { 6 | return (data: T) => ({ 7 | data, 8 | meta: { 9 | page: input.page, 10 | perPage: input.perPage, 11 | }, 12 | }); 13 | } 14 | export function withCursor(cursor: string) { 15 | return (data: T) => ({ 16 | data, 17 | meta: { 18 | cursor, 19 | }, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from "@trpc/server"; 2 | import { BrowserWindow, IpcMainInvokeEvent } from "electron"; 3 | import { Logger } from "~/src/shared/logger"; 4 | import { pushLogToClient } from "./events.ee"; 5 | const t = initTRPC.context<{ window: BrowserWindow; event: IpcMainInvokeEvent; log: Logger; path: string }>().create({ isServer: true }); 6 | export const router = t.router; 7 | /** 8 | * procedures that are allowed to be used everywhere 9 | */ 10 | export const publicProcedure = t.procedure.use(async ({ ctx, path, type, next }) => { 11 | const log = new Logger(path).child(type); 12 | ctx.log = log; 13 | ctx.path = path; 14 | return next({ ctx }).catch((err) => { 15 | if (err instanceof TRPCError) pushLogToClient(err.message, "error"); 16 | return Promise.reject(err); 17 | }); 18 | }); 19 | 20 | /** 21 | * procedure that only runs on main process 22 | */ 23 | export const mainProcedure = t.procedure.use(async ({ ctx, next }) => { 24 | if (ctx.window) throw new Error("Invalid Context, caller must be main process"); 25 | return next({ ctx }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/main/trpc/ytdlp.core.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@main/stores/app-database"; 2 | import { downloads } from "@main/stores/app-database.schema"; 3 | import { logger } from "@shared/logger"; 4 | import { inArray } from "drizzle-orm"; 5 | import { app } from "electron"; 6 | import { clamp } from "lodash"; 7 | import { availableParallelism } from "os"; 8 | import path from "path"; 9 | import { YTDLP } from "./ytdlp.utils"; 10 | 11 | export const ytdl = new YTDLP(); 12 | 13 | export const checkBrokenLinks = async () => { 14 | const itemCount = await db 15 | .update(downloads) 16 | .set({ state: "cancelled" }) 17 | .where(inArray(downloads.state, ["downloading", "fetching_meta", "queued", "converting"])); 18 | logger.info(`Updated state of ${itemCount.rowsAffected} to cancelled`); 19 | }; 20 | 21 | export const MAX_PARALLEL_TASKS = availableParallelism(); 22 | export const MAX_PARALLEL_DOWNLOADS = 2; 23 | export const MAX_STREAM_CONCURRENT_FRAGMENTS = clamp(MAX_PARALLEL_TASKS, 1, 6); 24 | export const YTDLP_CACHE_PATH = path.join(app.getPath("userData"), "ytdlp_cache"); 25 | -------------------------------------------------------------------------------- /src/main/trpc/ytdlp.ee.ts: -------------------------------------------------------------------------------- 1 | import { appStore } from "@main/stores/app.store"; 2 | import { logger } from "@shared/logger"; 3 | import EventEmitter from "events"; 4 | import PQueue from "p-queue"; 5 | import { MAX_PARALLEL_DOWNLOADS } from "./ytdlp.core"; 6 | export const ytdlpEvents = new EventEmitter(); 7 | ytdlpEvents.setMaxListeners(10000); 8 | 9 | export const ytdlpDownloadQueue = new PQueue({ 10 | concurrency: appStore.store.features.concurrentDownloads, 11 | }); 12 | const log = logger.child("YTDLPQueue"); 13 | appStore.onDidChange("features", (features) => { 14 | if (!features) return; 15 | ytdlpDownloadQueue.concurrency = features.concurrentDownloads ?? MAX_PARALLEL_DOWNLOADS; 16 | }); 17 | ytdlpDownloadQueue.on("active", () => { 18 | ytdlpEvents.emit("queue", { pending: ytdlpDownloadQueue.pending }); 19 | }); 20 | ytdlpDownloadQueue.on("next", () => { 21 | ytdlpEvents.emit("queue", { pending: ytdlpDownloadQueue.pending }); 22 | }); 23 | ytdlpDownloadQueue.on("error", (err) => { 24 | log.error(err); 25 | }); 26 | ytdlpDownloadQueue.on("completed", () => { 27 | ytdlpEvents.emit("queue", { pending: ytdlpDownloadQueue.pending }); 28 | }); 29 | export {}; 30 | -------------------------------------------------------------------------------- /src/main/updater/index.ts: -------------------------------------------------------------------------------- 1 | import { appStore } from "@main/stores/app.store"; 2 | import { isProduction } from "@shared/config"; 3 | import { app, BrowserWindow, dialog } from "electron"; 4 | import { autoUpdater, UpdateInfo } from "electron-updater"; 5 | import semver from "semver"; 6 | const [GITHUB_AUTHOR, GITHUB_REPOSITORY] = import.meta.env.VITE_GITHUB_REPOSITORY?.split("/", 2) ?? [null, null]; 7 | let updateQueuedInFrontend = false; 8 | export const setUpdateHandledByFrontend = (value: boolean) => (updateQueuedInFrontend = value); 9 | export function isUpdateInRange(ver: string) { 10 | if (!isProduction) return true; 11 | return semver.gtr(ver, app.getVersion(), { 12 | includePrerelease: appStore.store.beta, 13 | }); 14 | } 15 | export function checkForUpdates() { 16 | return autoUpdater.checkForUpdates().then((info) => (info && isUpdateInRange(info.updateInfo.version) && info) || null); 17 | } 18 | export function checkForUpdatesAndNotify() {} 19 | export async function proceedUpdateDialog(info: UpdateInfo) { 20 | const releaseNotes = (typeof info.releaseNotes === "string" ? info.releaseNotes : info.releaseNotes?.map((x) => x.note).join("\n"))?.replace(/<[^>]+>/g, "").trimStart(); 21 | return await dialog 22 | .showMessageBox({ 23 | title: `Update available (${info.version})`, 24 | message: `Hey there is a new version which you can update to.\n\n${process.platform === "win32" ? releaseNotes : info.releaseName}`, 25 | type: "question", 26 | buttons: ["Update now", "Update on quit", "Cancel"], 27 | cancelId: -1, 28 | }) 29 | .then(({ response }) => { 30 | if (response === 0) autoUpdater.quitAndInstall(); 31 | else if (response === 1) autoUpdater.autoInstallOnAppQuit = true; 32 | }); 33 | } 34 | export function attachAutoUpdaterIPC(win: BrowserWindow) { 35 | autoUpdater.on("update-available", (info) => info && isUpdateInRange(info.version) && win.webContents.send("update-available", info)); 36 | autoUpdater.on("update-not-available", (info) => { 37 | win.webContents.send("update-available", false); 38 | win.webContents.send("update-checking", false); 39 | }); 40 | autoUpdater.on("checking-for-update", () => win.webContents.send("update-checking", new Date().toISOString())); 41 | autoUpdater.signals.progress((info) => { 42 | win.webContents.send("update-download-progress", info); 43 | }); 44 | autoUpdater.signals.updateDownloaded(async (x) => { 45 | win.webContents.send("update-download-done", x); 46 | if (updateQueuedInFrontend) return; 47 | return await proceedUpdateDialog(x); 48 | }); 49 | if (!import.meta.env.VITE_GITHUB_REPOSITORY) 50 | autoUpdater.setFeedURL({ 51 | provider: "github", 52 | owner: GITHUB_AUTHOR, 53 | repo: GITHUB_REPOSITORY, 54 | }); 55 | autoUpdater.autoDownload = false; 56 | } 57 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ElectronAPI } from "@electron-toolkit/preload"; 2 | import type { platform } from "@electron-toolkit/utils"; 3 | import type { ProgressInfo, UpdateDownloadedEvent, UpdateInfo } from "electron-updater"; 4 | declare global { 5 | interface Window { 6 | electron: ElectronAPI; 7 | api: { 8 | version: string; 9 | platform: typeof platform; 10 | useMica: boolean; 11 | maxParallelism: number; 12 | on(eventName: string, handle: (ev: IpcRendererEvent, ...args: any[]) => void): void; 13 | invoke(actionName: string, ...args: any[]): Promise; 14 | off(eventName: string, handle: any): void; 15 | updater: { 16 | progress(handle: (_progress: ProgressInfo) => void): void; 17 | available(handle: (_info: UpdateInfo) => void): void; 18 | downloaded(handle: (_info: UpdateDownloadedEvent) => void): void; 19 | checking(handle: (dateStartedChecking: string) => void): void; 20 | checkForUpdates(): Promise; 21 | }; 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { electronAPI } from "@electron-toolkit/preload"; 2 | import platform from "@main/lib/platform"; 3 | import { IpcRendererEvent, contextBridge, ipcRenderer } from "electron"; 4 | import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main"; 5 | import { availableParallelism } from "node:os"; 6 | import { version } from "~/package.json"; 7 | import {} from "./index.d"; 8 | // Custom APIs for renderer 9 | const api = { 10 | version, 11 | platform, 12 | useMica: platform.isWindows && !!process.argv.find((d) => d === "--use-mica"), 13 | maxParallelism: availableParallelism(), 14 | on: (eventName: string, handle: (ev: IpcRendererEvent, ...args: any[]) => void) => ipcRenderer.on(eventName, handle), 15 | off: (eventName: string, handle: any) => ipcRenderer.off(eventName, handle), 16 | invoke(actionName, ...args) { 17 | return ipcRenderer.invoke(actionName, ...args); 18 | }, 19 | updater: { 20 | available(handle) { 21 | ipcRenderer.on("update-available", (_ev, data) => handle(data)); 22 | }, 23 | progress(handle) { 24 | ipcRenderer.on("update-download-progress", (_ev, data) => handle(data)); 25 | }, 26 | downloaded(handle) { 27 | ipcRenderer.on("update-download-done", (_ev, data) => handle(data)); 28 | }, 29 | checking(handle) { 30 | ipcRenderer.on("update-checking", (_ev, data) => handle(data)); 31 | }, 32 | checkForUpdates() { 33 | return ipcRenderer.invoke("update-check"); 34 | }, 35 | }, 36 | } satisfies typeof window.api; 37 | 38 | const electronTRPC = { 39 | sendMessage: (operation) => ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation), 40 | onMessage: (callback) => ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args)), 41 | }; 42 | 43 | // Use `contextBridge` APIs to expose Electron APIs to 44 | // renderer only if context isolation is enabled, otherwise 45 | // just add to the DOM global. 46 | if (process.contextIsolated) { 47 | try { 48 | contextBridge.exposeInMainWorld("electron", electronAPI); 49 | contextBridge.exposeInMainWorld("api", api); 50 | contextBridge.exposeInMainWorld("electronTRPC", electronTRPC); 51 | } catch (error) { 52 | console.error(error); 53 | } 54 | } else { 55 | // @ts-ignore (define in dts) 56 | window.electron = electronAPI; 57 | // @ts-ignore (define in dts) 58 | window.api = api; 59 | // @ts-ignore (define in dts) 60 | window.electronTRPC = electronTRPC; 61 | } 62 | 63 | process.on("loaded", async () => {}); 64 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/renderer/src/assets/frame-font.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Venipa/ytdlp-gui/291cb6afd83e415836f46e9faedb878b638d47a3/src/renderer/src/assets/frame-font.woff2 -------------------------------------------------------------------------------- /src/renderer/src/assets/wavy-lines.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/renderer/src/components/app/logo/Image.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLProps } from "react"; 2 | 3 | export default function Image(props: HTMLProps) { 4 | return logo; 5 | } 6 | -------------------------------------------------------------------------------- /src/renderer/src/components/app/logo/index.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLProps } from "react"; 2 | import LogoImage from "~/build/icon_1024x1024.png"; 3 | 4 | export default function Logo(props: HTMLProps) { 5 | return ( 6 |
7 | logo 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/src/components/app/theme-provider/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 4 | import { type ThemeProviderProps } from "next-themes/dist/types"; 5 | 6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/src/components/app/theme-toggle/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import { Laptop, Moon, Sun } from "lucide-react"; 5 | import { motion } from "motion/react"; 6 | import { useTheme } from "next-themes"; 7 | import * as React from "react"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | 11 | const toggleVariants = cva("inline-flex items-center justify-center rounded-full transition-colors hover:opacity-90", { 12 | variants: { 13 | theme: { 14 | light: "", 15 | dark: "", 16 | system: "", 17 | }, 18 | size: { 19 | sm: "h-8 w-8", 20 | md: "h-10 w-10", 21 | lg: "h-12 w-12", 22 | }, 23 | variant: { 24 | default: "", 25 | ghost: "", 26 | primary: "", 27 | }, 28 | }, 29 | defaultVariants: { 30 | theme: "light", 31 | size: "md", 32 | variant: "ghost", 33 | }, 34 | }); 35 | 36 | interface ThemeToggleProps extends VariantProps {} 37 | 38 | export function ThemeToggle({ size, variant }: ThemeToggleProps) { 39 | const { setTheme, theme } = useTheme(); 40 | const [mounted, setMounted] = React.useState(false); 41 | 42 | React.useEffect(() => setMounted(true), []); 43 | 44 | if (!mounted) { 45 | return null; 46 | } 47 | 48 | const toggleTheme = () => { 49 | if (theme === "light") { 50 | setTheme("dark"); 51 | } else if (theme === "dark") { 52 | setTheme("system"); 53 | } else { 54 | setTheme("light"); 55 | } 56 | }; 57 | 58 | const iconSize = { 59 | sm: "h-4 w-4", 60 | md: "h-5 w-5", 61 | lg: "h-6 w-6", 62 | }[size || "md"]; 63 | 64 | return ( 65 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/renderer/src/components/baseLayout.tsx: -------------------------------------------------------------------------------- 1 | import AppNavBar from "@renderer/components/ui/app-navbar"; 2 | import { useWindowState } from "@renderer/lib/useWindowState"; 3 | import { cn } from "@renderer/lib/utils"; 4 | import { motion, useScroll, useTransform } from "motion/react"; 5 | import { PropsWithChildren, useMemo } from "react"; 6 | import { useLocation } from "react-router-dom"; 7 | import { TooltipProvider } from "./ui/tooltip"; 8 | const translucentPaths = ["/", "/onboarding", "/onboarding/completed", "/settings", "/license"]; 9 | export function BaseLayoutComponent({ children }: PropsWithChildren) { 10 | const { pathname } = useLocation(); 11 | const { windowState } = useWindowState(); 12 | console.log({ pathname }); 13 | const { scrollY } = useScroll(); 14 | const gradientValue = useTransform(scrollY, [0, 50], [0.0, 1.0]); 15 | const isTranslucentRoute = useMemo(() => translucentPaths.includes(pathname), [pathname]); 16 | return ( 17 | 18 | 19 | 20 |
{children}
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/ButtonLoading.tsx: -------------------------------------------------------------------------------- 1 | import { sn } from "@renderer/lib/utils"; 2 | import { forwardRef, MouseEvent, useCallback, useEffect, useState } from "react"; 3 | import { Button, ButtonProps } from "./button"; 4 | import { Spinner } from "./spinner"; 5 | interface ButtonLoadingProps extends ButtonProps { 6 | loading?: boolean; 7 | onClickWithLoading?: () => Promise; 8 | fixWidth?: boolean; 9 | } 10 | export default forwardRef(function ButtonLoading({ children, loading: refLoading, onClickWithLoading, fixWidth, ...props }: ButtonLoadingProps, ref: any) { 11 | const [loading, setLoading] = useState(() => !!refLoading); 12 | const [fixedWidth, setFixedWidth] = useState(); 13 | const handleClick = useCallback( 14 | (ev: MouseEvent) => { 15 | if (fixWidth) setFixedWidth(ev.currentTarget.clientWidth); 16 | if (onClickWithLoading) { 17 | setLoading(true); 18 | onClickWithLoading().finally(() => setLoading(false)); 19 | } 20 | }, 21 | [props.onClick], 22 | ); 23 | useEffect(() => { 24 | setLoading(!!refLoading); 25 | }, [refLoading]); 26 | return ( 27 | 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 4 | import { ChevronDownIcon } from "@radix-ui/react-icons"; 5 | import * as React from "react"; 6 | 7 | import { cn } from "@renderer/lib/utils"; 8 | 9 | const Accordion = AccordionPrimitive.Root; 10 | 11 | const AccordionItem = React.forwardRef, React.ComponentPropsWithoutRef>( 12 | ({ className, ...props }, ref) => , 13 | ); 14 | AccordionItem.displayName = "AccordionItem"; 15 | 16 | const AccordionTrigger = React.forwardRef, React.ComponentPropsWithoutRef>( 17 | ({ className, children, ...props }, ref) => ( 18 | 19 | svg]:rotate-180", className)} 22 | {...props}> 23 | {children} 24 | 25 | 26 | 27 | ), 28 | ); 29 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 30 | 31 | const AccordionContent = React.forwardRef, React.ComponentPropsWithoutRef>( 32 | ({ className, children, ...props }, ref) => ( 33 | 34 |
{children}
35 |
36 | ), 37 | ); 38 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 39 | 40 | export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; 41 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@renderer/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 13 | }, 14 | }, 15 | defaultVariants: { 16 | variant: "default", 17 | }, 18 | }, 19 | ); 20 | 21 | const Alert = React.forwardRef & VariantProps>(({ className, variant, ...props }, ref) => ( 22 |
23 | )); 24 | Alert.displayName = "Alert"; 25 | 26 | const AlertTitle = React.forwardRef>(({ className, ...props }, ref) => ( 27 |
28 | )); 29 | AlertTitle.displayName = "AlertTitle"; 30 | 31 | const AlertDescription = React.forwardRef>(({ className, ...props }, ref) => ( 32 |
33 | )); 34 | AlertDescription.displayName = "AlertDescription"; 35 | 36 | export { Alert, AlertDescription, AlertTitle }; 37 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/app-navbar.tsx: -------------------------------------------------------------------------------- 1 | import { WindowControlBar, WindowsControlBarProps } from "@renderer/components/ui/window/control-bar"; 2 | import { useWindowConfig, useWindowControls, useWindowState } from "@renderer/lib/useWindowState"; 3 | import { cn } from "@renderer/lib/utils"; 4 | import { useMemo, useRef } from "react"; 5 | import { useLocation } from "react-router-dom"; 6 | import { useEventListener } from "usehooks-ts"; 7 | import { ThemeToggle } from "../app/theme-toggle"; 8 | import { Separator } from "./separator"; 9 | import { Spinner } from "./spinner"; 10 | import { QTooltip, TooltipProvider } from "./tooltip"; 11 | const quitPaths: string[] = []; 12 | export default function AppNavBar({ className, ...props }: { className?: string } & WindowsControlBarProps) { 13 | const windowRef = useRef(document); 14 | const [configState, setWindowConfig] = useWindowConfig(); 15 | const { pathname } = useLocation(); 16 | const isQuitRoute = useMemo(() => quitPaths.includes(pathname), [pathname]); 17 | const { windowState } = useWindowState(); 18 | const { hide, close, maximize: onMaximize, minimize: onMinimize } = useWindowControls(); 19 | useEventListener( 20 | "keydown", 21 | (ev) => { 22 | if (ev.key === "Escape" && windowState?.parentId) close(); 23 | }, 24 | windowRef, 25 | { passive: true }, 26 | ); 27 | return ( 28 | 29 | } 31 | state={{ ...windowState, title: "" } as any} 32 | className={cn("h-10 px-1.5", className)} 33 | {...{ onMinimize, onMaximize, onClose: isQuitRoute ? close : hide }} 34 | {...props}> 35 | {windowState && ( 36 | <> 37 |
38 | 39 | 40 | 41 | {(props.variant !== "transparent" && ) ||
} 42 |
43 | 44 | )} 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root; 6 | 7 | export { AspectRatio }; 8 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@renderer/lib/utils"; 7 | 8 | const Avatar = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( 9 | 10 | )); 11 | Avatar.displayName = AvatarPrimitive.Root.displayName; 12 | 13 | const AvatarImage = React.forwardRef, React.ComponentPropsWithoutRef>( 14 | ({ className, ...props }, ref) => , 15 | ); 16 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 17 | 18 | const AvatarFallback = React.forwardRef, React.ComponentPropsWithoutRef>( 19 | ({ className, ...props }, ref) => ( 20 | 21 | ), 22 | ); 23 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 24 | 25 | export { Avatar, AvatarFallback, AvatarImage }; 26 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from "class-variance-authority"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@renderer/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 select-none cursor-default", 8 | { 9 | variants: { 10 | variant: { 11 | default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 12 | secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 13 | destructive: "border-destructive bg-destructive/20 text-destructive-foreground shadow hover:bg-destructive/30", 14 | outline: "text-foreground", 15 | }, 16 | size: { 17 | default: "px-2.5 py-0.5 text-xs gap-2 [&_svg]:size-4", 18 | sm: "px-1.5 py-0 text-xs gap-1.5 [&_svg]:size-4", 19 | }, 20 | }, 21 | defaultVariants: { 22 | variant: "default", 23 | size: "default", 24 | }, 25 | }, 26 | ); 27 | 28 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return
; 32 | } 33 | 34 | export { Badge, badgeVariants }; 35 | -------------------------------------------------------------------------------- /src/renderer/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@renderer/lib/utils"; 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode; 11 | } 12 | >(({ ...props }, ref) =>