Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ab8dce60 | ||
|
|
c1806e5321 | ||
|
|
92494966c6 | ||
|
|
f04680b8c2 | ||
|
|
a5b94411fe | ||
|
|
71ae4a4425 | ||
|
|
e8413aefae | ||
|
|
b229225b6e | ||
|
|
5497ed6245 | ||
|
|
155bf652f2 | ||
|
|
86bd77d995 | ||
|
|
b38b356c22 |
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a problem or regression
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: app_version
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: The VidBee version (e.g., 1.2.3)
|
||||||
|
placeholder: 1.2.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: os_version
|
||||||
|
attributes:
|
||||||
|
label: OS version
|
||||||
|
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
|
||||||
|
placeholder: macOS 14.2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Observed behavior
|
||||||
|
placeholder: What actually happened
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs or screenshots
|
||||||
|
description: Paste relevant logs or add screenshots
|
||||||
|
placeholder: Attach files or paste logs here
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
38
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
38
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem to solve
|
||||||
|
description: What problem are you trying to solve?
|
||||||
|
placeholder: I want to...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe the feature or change you want
|
||||||
|
placeholder: It would be great if...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Other solutions or workarounds you considered
|
||||||
|
placeholder: I tried...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context or screenshots
|
||||||
|
placeholder: Links, screenshots, or related issues
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
@@ -76,10 +76,10 @@ jobs:
|
|||||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||||
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
||||||
|
|
||||||
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||||
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
||||||
|
|
||||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||||
@@ -103,7 +103,11 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||||
|
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
|
||||||
|
echo "::error::Downloaded ffmpeg archive is not a valid tar.xz"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
mkdir ffmpeg
|
mkdir ffmpeg
|
||||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||||
@@ -112,7 +116,7 @@ jobs:
|
|||||||
- name: Download yt-dlp binary
|
- name: Download yt-dlp binary
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
||||||
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||||
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
||||||
fi
|
fi
|
||||||
@@ -130,4 +134,3 @@ jobs:
|
|||||||
name: dist-${{ matrix.os }}
|
name: dist-${{ matrix.os }}
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"setup": "cp $CONDUCTOR_ROOT_PATH/resources/ resources/ && pnpm install",
|
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
|
||||||
"run": "pnpm run dev"
|
"run": "pnpm run dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vidbee",
|
"name": "vidbee",
|
||||||
"version": "1.1.1",
|
"version": "1.1.4",
|
||||||
"description": "A modern Electron application for downloading videos and audios",
|
"description": "A modern Electron application for downloading videos and audios",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "VidBee",
|
"author": "VidBee",
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"electron-ipc-decorator": "^0.2.0",
|
"electron-ipc-decorator": "^0.2.0",
|
||||||
|
|||||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.7
|
specifier: ^2.1.7
|
||||||
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.7
|
specifier: ^1.1.7
|
||||||
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -80,6 +83,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
cmdk:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.18
|
specifier: ^1.11.18
|
||||||
version: 1.11.18
|
version: 1.11.18
|
||||||
@@ -999,6 +1005,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15':
|
||||||
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8':
|
'@radix-ui/react-popper@1.2.8':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1896,6 +1915,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cmdk@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -4481,6 +4506,29 @@ snapshots:
|
|||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -5429,6 +5477,18 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
@@ -104,40 +104,80 @@ function ensureDir(dir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeUnlink(filePath) {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadHeaders(url) {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'vidbee-setup',
|
||||||
|
Accept: '*/*'
|
||||||
|
}
|
||||||
|
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
|
||||||
|
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
function downloadFile(url, dest) {
|
function downloadFile(url, dest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
const file = fs.createWriteStream(dest)
|
const file = fs.createWriteStream(dest)
|
||||||
|
|
||||||
protocol
|
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||||
.get(url, (response) => {
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
// Handle redirect
|
||||||
// Handle redirect
|
|
||||||
file.close()
|
|
||||||
fs.unlinkSync(dest)
|
|
||||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
file.close()
|
|
||||||
fs.unlinkSync(dest)
|
|
||||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
response.pipe(file)
|
|
||||||
file.on('finish', () => {
|
|
||||||
file.close()
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
file.close()
|
file.close()
|
||||||
fs.unlinkSync(dest)
|
safeUnlink(dest)
|
||||||
reject(err)
|
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
file.close()
|
||||||
|
safeUnlink(dest)
|
||||||
|
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
response.pipe(file)
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close()
|
||||||
|
resolve()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
request.setTimeout(30000, () => {
|
||||||
|
request.destroy(new Error('Download timeout'))
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('error', (err) => {
|
||||||
|
file.close()
|
||||||
|
safeUnlink(dest)
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||||
|
let lastError
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await downloadFile(url, dest)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
safeUnlink(dest)
|
||||||
|
if (attempt < retries) {
|
||||||
|
const backoff = delayMs * attempt
|
||||||
|
log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
function fetchJson(url) {
|
function fetchJson(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
@@ -299,7 +339,7 @@ async function downloadYtDlp(config) {
|
|||||||
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(url, tempPath)
|
await downloadFileWithRetry(url, tempPath)
|
||||||
fs.renameSync(tempPath, outputPath)
|
fs.renameSync(tempPath, outputPath)
|
||||||
setExecutable(outputPath)
|
setExecutable(outputPath)
|
||||||
log(`Downloaded ${output} successfully`, 'success')
|
log(`Downloaded ${output} successfully`, 'success')
|
||||||
@@ -342,7 +382,7 @@ async function downloadFfmpegWindows(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
@@ -397,7 +437,7 @@ async function downloadFfmpegMac(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
@@ -451,7 +491,7 @@ async function downloadFfmpegLinux(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempTar)
|
await downloadFileWithRetry(downloadUrl, tempTar)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractTarXz(tempTar, extractDir)
|
extractTarXz(tempTar, extractDir)
|
||||||
|
|
||||||
@@ -498,7 +538,7 @@ async function downloadDenoRuntime() {
|
|||||||
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting Deno runtime...', 'info')
|
log('Extracting Deno runtime...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
|
|||||||
const format = options.format
|
const format = options.format
|
||||||
const audioFormat = options.audioFormat
|
const audioFormat = options.audioFormat
|
||||||
|
|
||||||
|
if (format && audioFormat === '') {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||||
return format
|
return format
|
||||||
}
|
}
|
||||||
@@ -30,8 +34,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
|
|||||||
return 'bestvideo+none'
|
return 'bestvideo+none'
|
||||||
}
|
}
|
||||||
if (!audioFormat || audioFormat === 'best') {
|
if (!audioFormat || audioFormat === 'best') {
|
||||||
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
|
// Prefer merged formats, but allow single-file "best" for sites without separate streams.
|
||||||
return 'bestvideo+bestaudio'
|
return 'bestvideo+bestaudio/best'
|
||||||
}
|
}
|
||||||
return `bestvideo+${audioFormat}`
|
return `bestvideo+${audioFormat}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
|||||||
export function createWindow(): void {
|
export function createWindow(): void {
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
|
const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin
|
||||||
|
|
||||||
const windowOptions: BrowserWindowConstructorOptions = {
|
const windowOptions: BrowserWindowConstructorOptions = {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
@@ -168,6 +169,9 @@ export function createWindow(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
if (shouldStartHidden) {
|
||||||
|
return
|
||||||
|
}
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ interface DownloadProcess {
|
|||||||
process: YTDlpEventEmitter
|
process: YTDlpEventEmitter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYtDlpCommand = (args: string[]): string => {
|
||||||
|
const quoted = args.map((arg) => {
|
||||||
|
if (arg === '') {
|
||||||
|
return '""'
|
||||||
|
}
|
||||||
|
if (/[\s"'\\]/.test(arg)) {
|
||||||
|
return `"${arg.replace(/(["\\])/g, '\\$1')}"`
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
})
|
||||||
|
return `yt-dlp ${quoted.join(' ')}`
|
||||||
|
}
|
||||||
|
|
||||||
const ensureDirectoryExists = (dir?: string): void => {
|
const ensureDirectoryExists = (dir?: string): void => {
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
return
|
return
|
||||||
@@ -732,6 +745,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
args.push('--ffmpeg-location', ffmpegPath)
|
args.push('--ffmpeg-location', ffmpegPath)
|
||||||
args.push(urlArg)
|
args.push(urlArg)
|
||||||
|
|
||||||
|
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const ytdlpProcess = ytdlp.exec(args, {
|
const ytdlpProcess = ytdlp.exec(args, {
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ class SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAll(): AppSettings {
|
getAll(): AppSettings {
|
||||||
return this.store.store
|
return {
|
||||||
|
...defaultSettings,
|
||||||
|
downloadPath: DEFAULT_DOWNLOAD_PATH,
|
||||||
|
...this.store.store
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAll(settings: Partial<AppSettings>): void {
|
setAll(settings: Partial<AppSettings>): void {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Settings } from './pages/Settings'
|
|||||||
import { Subscriptions } from './pages/Subscriptions'
|
import { Subscriptions } from './pages/Subscriptions'
|
||||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||||
|
import { updateAvailableAtom, updateReadyAtom } from './store/update'
|
||||||
|
|
||||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ function AppContent() {
|
|||||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
|
const setUpdateReady = useSetAtom(updateReadyAtom)
|
||||||
|
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const updateDownloadInProgressRef = useRef(false)
|
const updateDownloadInProgressRef = useRef(false)
|
||||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||||
@@ -174,8 +177,25 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateDownloaded = (_rawInfo: unknown) => {
|
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||||
|
const info = (rawInfo ?? {}) as { version?: string }
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: info.version
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||||
|
const info = (rawInfo ?? {}) as { version?: string }
|
||||||
resetDownloadState()
|
resetDownloadState()
|
||||||
|
setUpdateReady({
|
||||||
|
ready: true,
|
||||||
|
version: info.version
|
||||||
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: info.version
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateError = (rawMessage: unknown) => {
|
const handleUpdateError = (rawMessage: unknown) => {
|
||||||
@@ -211,19 +231,21 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only listen to update events that should be shown globally
|
// Only listen to update events that should be shown globally
|
||||||
// update:available is handled in About page only
|
// update:available shows a visual indicator in the sidebar
|
||||||
|
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||||
ipcEvents.on('update:error', handleUpdateError)
|
ipcEvents.on('update:error', handleUpdateError)
|
||||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [setUpdateAvailable, setUpdateReady, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row h-screen">
|
<div className="flex flex-row h-screen">
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
|
|||||||
|
|
||||||
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
||||||
if (preset === 'worst') {
|
if (preset === 'worst') {
|
||||||
return ['worstaudio']
|
return dedupe(['worstaudio', 'bestaudio'])
|
||||||
}
|
}
|
||||||
|
|
||||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||||
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
|
// Prefer audio-only selectors so video+audio merges remain valid.
|
||||||
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
|
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +90,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
|
|||||||
const preset = getQualityPreset(settings)
|
const preset = getQualityPreset(settings)
|
||||||
|
|
||||||
if (preset === 'worst') {
|
if (preset === 'worst') {
|
||||||
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
|
// Prefer separate streams, then fall back to single-file selectors.
|
||||||
return 'worstvideo+worstaudio'
|
return 'worstvideo+worstaudio/worst/best'
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxHeight = qualityPresetToVideoHeight[preset]
|
const maxHeight = qualityPresetToVideoHeight[preset]
|
||||||
@@ -114,16 +114,18 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
|
|||||||
combinations.push(video)
|
combinations.push(video)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
|
// Prefer merged formats, then allow 'best' as a compatibility fallback.
|
||||||
combinations.push('bestvideo+bestaudio')
|
combinations.push('bestvideo+bestaudio')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
combinations.push('best')
|
||||||
|
|
||||||
return dedupe(combinations).join('/')
|
return dedupe(combinations).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildAudioFormatPreference = (settings: AppSettings): string => {
|
const buildAudioFormatPreference = (settings: AppSettings): string => {
|
||||||
const selectors = buildAudioSelectors(getQualityPreset(settings))
|
const selectors = buildAudioSelectors(getQualityPreset(settings))
|
||||||
return selectors.join('/')
|
return dedupe([...selectors, 'best']).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadDialogProps {
|
interface DownloadDialogProps {
|
||||||
@@ -788,11 +790,12 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
type,
|
type,
|
||||||
format:
|
format:
|
||||||
type === 'video'
|
type === 'video'
|
||||||
? videoInfoCardState.selectedVideoFormat
|
? videoInfoCardState.selectedVideoFormat || undefined
|
||||||
: type === 'extract'
|
: type === 'extract'
|
||||||
? undefined
|
? undefined
|
||||||
: videoInfoCardState.selectedAudioFormat,
|
: videoInfoCardState.selectedAudioFormat || undefined,
|
||||||
audioFormat: type === 'video' ? videoInfoCardState.selectedAudioForVideo : undefined,
|
audioFormat:
|
||||||
|
type === 'video' ? videoInfoCardState.selectedAudioForVideo || undefined : undefined,
|
||||||
extractFormat:
|
extractFormat:
|
||||||
type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined,
|
type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined,
|
||||||
extractQuality:
|
extractQuality:
|
||||||
@@ -935,29 +938,33 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
{t('sites.viewAll')}
|
{t('sites.viewAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings.oneClickDownload && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-primary">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
|
|
||||||
<span>{t('download.oneClickDownloadEnabled')}</span>
|
|
||||||
{onOpenSettings && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs h-auto"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onOpenSettings()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('download.goToSettings')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* One-click download indicator */}
|
||||||
|
{settings.oneClickDownload && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{t('download.oneClickDownloadEnabled')}</span>
|
||||||
|
{onOpenSettings && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onOpenSettings()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('download.goToSettings')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
||||||
|
|||||||
39
src/renderer/src/components/ui/popover.tsx
Normal file
39
src/renderer/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@@ -1,22 +1,13 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '@renderer/components/ui/dropdown-menu'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||||
import { saveSettingAtom } from '@renderer/store/settings'
|
import { useAtom } from 'jotai'
|
||||||
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import '../../assets/title-bar.css'
|
import '../../assets/title-bar.css'
|
||||||
|
import { updateAvailableAtom } from '@renderer/store/update'
|
||||||
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
||||||
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
||||||
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
||||||
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
||||||
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
|
||||||
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||||
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
||||||
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
||||||
@@ -53,10 +44,8 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const saveSetting = useSetAtom(saveSettingAtom)
|
const [updateAvailable] = useAtom(updateAvailableAtom)
|
||||||
|
|
||||||
const languageOptions = languageList
|
|
||||||
|
|
||||||
const navigationItems: NavigationItem[] = [
|
const navigationItems: NavigationItem[] = [
|
||||||
{
|
{
|
||||||
@@ -105,20 +94,6 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeLanguageCode = normalizeLanguageCode(i18n.language)
|
|
||||||
const currentLanguage =
|
|
||||||
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
|
||||||
|
|
||||||
const handleLanguageChange = async (value: LanguageCode) => {
|
|
||||||
if (activeLanguageCode === value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveSetting({ key: 'language', value })
|
|
||||||
await i18n.changeLanguage(value)
|
|
||||||
toast.success(t('notifications.settingsSaved'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
|
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
|
||||||
const isActive = item.id !== 'supported-sites' && currentPage === item.id
|
const isActive = item.id !== 'supported-sites' && currentPage === item.id
|
||||||
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
||||||
@@ -161,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Language Selector */}
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
|
|
||||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>{t('settings.language')}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent side="right" align="end">
|
|
||||||
{languageOptions.map((option) => {
|
|
||||||
const isActive = option.value === currentLanguage.value
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => void handleLanguageChange(option.value)}
|
|
||||||
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
|
|
||||||
aria-current={isActive}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
|
|
||||||
<span lang={option.hreflang}>{option.name}</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Navigation Items */}
|
{/* Bottom Navigation Items */}
|
||||||
{bottomNavigationItems.map((item) => {
|
{bottomNavigationItems.map((item) => {
|
||||||
const isActive = currentPage === item.id
|
const isActive = currentPage === item.id
|
||||||
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
||||||
|
const showUpdateDot = item.id === 'about' && updateAvailable.available
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||||
@@ -211,9 +150,14 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onPageChange(item.id)}
|
onClick={() => onPageChange(item.id)}
|
||||||
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
className={`no-drag rounded-2xl w-12 h-12 relative ${
|
||||||
|
isActive ? 'bg-primary/10' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||||
|
{showUpdateDot ? (
|
||||||
|
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500" />
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
|
|||||||
@@ -73,23 +73,31 @@ export function FormatSelector({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Filter and sort formats
|
// Filter and sort formats
|
||||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||||
const videos = formats.filter(
|
const isVideoFormat = (format: VideoFormat) =>
|
||||||
(f) =>
|
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
|
||||||
f.video_ext !== 'none' &&
|
const isAudioFormat = (format: VideoFormat) =>
|
||||||
f.vcodec &&
|
format.acodec &&
|
||||||
f.vcodec !== 'none' &&
|
format.acodec !== 'none' &&
|
||||||
f.protocol !== 'm3u8' &&
|
(format.video_ext === 'none' || !format.video_ext)
|
||||||
f.protocol !== 'm3u8_native'
|
const isHlsFormat = (format: VideoFormat) =>
|
||||||
|
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||||
|
|
||||||
|
const videoCandidates = formats.filter(
|
||||||
|
(format) => isVideoFormat(format) && !isHlsFormat(format)
|
||||||
)
|
)
|
||||||
const audios = formats.filter(
|
const audioCandidates = formats.filter(
|
||||||
(f) =>
|
(format) => isAudioFormat(format) && !isHlsFormat(format)
|
||||||
f.acodec &&
|
|
||||||
f.acodec !== 'none' &&
|
|
||||||
(f.video_ext === 'none' || !f.video_ext) &&
|
|
||||||
f.protocol !== 'm3u8' &&
|
|
||||||
f.protocol !== 'm3u8_native'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const videos =
|
||||||
|
videoCandidates.length > 0
|
||||||
|
? videoCandidates
|
||||||
|
: formats.filter((format) => isVideoFormat(format))
|
||||||
|
const audios =
|
||||||
|
audioCandidates.length > 0
|
||||||
|
? audioCandidates
|
||||||
|
: formats.filter((format) => isAudioFormat(format))
|
||||||
|
|
||||||
// Apply showMoreFormats filter
|
// Apply showMoreFormats filter
|
||||||
const filteredVideos = settings.showMoreFormats
|
const filteredVideos = settings.showMoreFormats
|
||||||
? videos
|
? videos
|
||||||
@@ -99,6 +107,9 @@ export function FormatSelector({
|
|||||||
? audios
|
? audios
|
||||||
: audios.filter((f) => f.ext !== 'webm')
|
: audios.filter((f) => f.ext !== 'webm')
|
||||||
|
|
||||||
|
const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos
|
||||||
|
const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios
|
||||||
|
|
||||||
// Sort formats by quality (best first)
|
// Sort formats by quality (best first)
|
||||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||||
// Sort by height (higher is better)
|
// Sort by height (higher is better)
|
||||||
@@ -138,23 +149,34 @@ export function FormatSelector({
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredVideos.sort(sortVideoFormatsByQuality)
|
finalVideos.sort(sortVideoFormatsByQuality)
|
||||||
filteredAudios.sort(sortAudioFormatsByQuality)
|
finalAudios.sort(sortAudioFormatsByQuality)
|
||||||
|
|
||||||
setVideoFormats(filteredVideos)
|
setVideoFormats(finalVideos)
|
||||||
setAudioFormats(filteredAudios)
|
setAudioFormats(finalAudios)
|
||||||
|
|
||||||
// Auto-select best format based on preferences
|
// Auto-select best format based on preferences.
|
||||||
if (filteredVideos.length > 0 && !selectedVideo) {
|
// If no separate audio formats exist, prefer muxed video formats (with audio).
|
||||||
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
|
const videosWithAudio = finalVideos.filter(
|
||||||
|
(format) => format.acodec && format.acodec !== 'none'
|
||||||
|
)
|
||||||
|
const autoVideos =
|
||||||
|
finalAudios.length > 0
|
||||||
|
? finalVideos
|
||||||
|
: videosWithAudio.length > 0
|
||||||
|
? videosWithAudio
|
||||||
|
: finalVideos
|
||||||
|
|
||||||
|
if (autoVideos.length > 0 && !selectedVideo) {
|
||||||
|
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
|
||||||
if (preferred) {
|
if (preferred) {
|
||||||
setSelectedVideo(preferred.format_id)
|
setSelectedVideo(preferred.format_id)
|
||||||
onVideoFormatChange?.(preferred.format_id)
|
onVideoFormatChange?.(preferred.format_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredAudios.length > 0 && !selectedAudio) {
|
if (finalAudios.length > 0 && !selectedAudio) {
|
||||||
const best = filteredAudios[0]
|
const best = finalAudios[0]
|
||||||
setSelectedAudio(best.format_id)
|
setSelectedAudio(best.format_id)
|
||||||
onAudioFormatChange?.(best.format_id)
|
onAudioFormatChange?.(best.format_id)
|
||||||
}
|
}
|
||||||
@@ -215,67 +237,79 @@ export function FormatSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
|
if (videoFormats.length === 0 && audioFormats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-3">
|
{videoFormats.length > 0 && (
|
||||||
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
<div className="space-y-3">
|
||||||
<Select
|
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
||||||
value={selectedVideo}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={selectedVideo}
|
||||||
setSelectedVideo(value)
|
onValueChange={(value) => {
|
||||||
onVideoFormatChange?.(value)
|
setSelectedVideo(value)
|
||||||
}}
|
onVideoFormatChange?.(value)
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-11">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger className="h-11">
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent className="max-h-[300px] p-1.5">
|
</SelectTrigger>
|
||||||
{videoFormats.map((format) => (
|
<SelectContent className="max-h-[300px] p-1.5">
|
||||||
<SelectItem
|
{videoFormats.map((format) => (
|
||||||
key={format.format_id}
|
<SelectItem
|
||||||
value={format.format_id}
|
key={format.format_id}
|
||||||
className="cursor-pointer py-2.5"
|
value={format.format_id}
|
||||||
>
|
className="cursor-pointer py-2.5"
|
||||||
<span className="text-sm">{formatVideoLabel(format)}</span>
|
>
|
||||||
</SelectItem>
|
<span className="text-sm">{formatVideoLabel(format)}</span>
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{audioFormats.length > 0 && (
|
||||||
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
<div className="space-y-3">
|
||||||
<Select
|
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
||||||
value={selectedAudio}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={selectedAudio}
|
||||||
setSelectedAudio(value)
|
onValueChange={(value) => {
|
||||||
onAudioFormatChange?.(value)
|
setSelectedAudio(value)
|
||||||
}}
|
onAudioFormatChange?.(value)
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-11">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger className="h-11">
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent className="max-h-[300px] p-1.5">
|
</SelectTrigger>
|
||||||
<SelectItem value="none" className="cursor-pointer py-2.5">
|
<SelectContent className="max-h-[300px] p-1.5">
|
||||||
<span className="text-sm">{t('download.noAudio')}</span>
|
<SelectItem value="none" className="cursor-pointer py-2.5">
|
||||||
</SelectItem>
|
<span className="text-sm">{t('download.noAudio')}</span>
|
||||||
{audioFormats.map((format) => (
|
|
||||||
<SelectItem
|
|
||||||
key={format.format_id}
|
|
||||||
value={format.format_id}
|
|
||||||
className="cursor-pointer py-2.5"
|
|
||||||
>
|
|
||||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
{audioFormats.map((format) => (
|
||||||
</SelectContent>
|
<SelectItem
|
||||||
</Select>
|
key={format.format_id}
|
||||||
</div>
|
value={format.format_id}
|
||||||
|
className="cursor-pointer py-2.5"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio only
|
// Audio only
|
||||||
|
if (audioFormats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
||||||
|
|||||||
@@ -50,10 +50,12 @@
|
|||||||
"documentation": "Help center",
|
"documentation": "Help center",
|
||||||
"documentationDescription": "Guides, FAQs, and common workflows.",
|
"documentationDescription": "Guides, FAQs, and common workflows.",
|
||||||
"feedback": "Feedback & issues",
|
"feedback": "Feedback & issues",
|
||||||
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
"feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
|
||||||
"githubIssues": "GitHub Issues",
|
"githubIssues": "GitHub",
|
||||||
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
||||||
"discord": "Discord Community",
|
"xFeedback": "Twitter",
|
||||||
|
"xFeedbackDescription": "Share feedback or suggestions on X by mentioning @nexmoex.",
|
||||||
|
"discord": "Discord",
|
||||||
"discordDescription": "Join our Discord community for discussions and support.",
|
"discordDescription": "Join our Discord community for discussions and support.",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"licenseDescription": "Review the open-source license terms.",
|
"licenseDescription": "Review the open-source license terms.",
|
||||||
@@ -379,6 +381,7 @@
|
|||||||
"fileSelectError": "Failed to select file",
|
"fileSelectError": "Failed to select file",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"languageDescription": "Choose your preferred language for the application interface",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"hideDockIcon": "Hide Dock icon",
|
"hideDockIcon": "Hide Dock icon",
|
||||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Badge } from '@renderer/components/ui/badge'
|
import { Badge } from '@renderer/components/ui/badge'
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from '@renderer/components/ui/card'
|
|
||||||
import { Progress } from '@renderer/components/ui/progress'
|
import { Progress } from '@renderer/components/ui/progress'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
@@ -18,14 +12,16 @@ import {
|
|||||||
Github,
|
Github,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Twitter
|
Twitter
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||||
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
||||||
|
import { updateAvailableAtom, updateReadyAtom } from '../store/update'
|
||||||
|
|
||||||
interface AboutResource {
|
interface AboutResource {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -45,6 +41,8 @@ type LatestVersionState =
|
|||||||
export function About() {
|
export function About() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||||
|
const [updateReady] = useAtom(updateReadyAtom)
|
||||||
|
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||||
const [appVersion, setAppVersion] = useState<string>('—')
|
const [appVersion, setAppVersion] = useState<string>('—')
|
||||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
||||||
@@ -88,6 +86,10 @@ export function About() {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
version: versionLabel
|
version: versionLabel
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: versionLabel
|
||||||
|
})
|
||||||
// Reset download progress when new update is available
|
// Reset download progress when new update is available
|
||||||
setUpdateDownloadProgress(0)
|
setUpdateDownloadProgress(0)
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ export function About() {
|
|||||||
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
||||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [setUpdateAvailable, t])
|
||||||
|
|
||||||
const handleSettingChange = async (
|
const handleSettingChange = async (
|
||||||
key: keyof typeof settings,
|
key: keyof typeof settings,
|
||||||
@@ -135,6 +137,10 @@ export function About() {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
version: result.version ?? ''
|
version: result.version ?? ''
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: result.version
|
||||||
|
})
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||||
setLatestVersionState({
|
setLatestVersionState({
|
||||||
@@ -147,6 +153,10 @@ export function About() {
|
|||||||
status: 'uptodate',
|
status: 'uptodate',
|
||||||
version: result.version ?? appVersion
|
version: result.version ?? appVersion
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check for updates:', error)
|
console.error('Failed to check for updates:', error)
|
||||||
@@ -159,6 +169,10 @@ export function About() {
|
|||||||
openShareUrl('https://vidbee.org/download/')
|
openShareUrl('https://vidbee.org/download/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRestartToUpdate = () => {
|
||||||
|
void ipcServices.update.quitAndInstall()
|
||||||
|
}
|
||||||
|
|
||||||
const handleCheckForUpdates = async () => {
|
const handleCheckForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
toast.info(t('about.notifications.checkingUpdates'))
|
toast.info(t('about.notifications.checkingUpdates'))
|
||||||
@@ -170,6 +184,10 @@ export function About() {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
version: result.version ?? ''
|
version: result.version ?? ''
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: result.version
|
||||||
|
})
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||||
setLatestVersionState({
|
setLatestVersionState({
|
||||||
@@ -182,6 +200,10 @@ export function About() {
|
|||||||
status: 'uptodate',
|
status: 'uptodate',
|
||||||
version: result.version ?? appVersion
|
version: result.version ?? appVersion
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check for updates:', error)
|
console.error('Failed to check for updates:', error)
|
||||||
@@ -202,13 +224,13 @@ export function About() {
|
|||||||
}
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const openShareUrl = (url: string) => {
|
const openShareUrl = useCallback((url: string) => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleShareTwitter = () => {
|
const handleShareTwitter = () => {
|
||||||
openShareUrl(shareLinks.twitter)
|
openShareUrl(shareLinks.twitter)
|
||||||
@@ -243,6 +265,39 @@ export function About() {
|
|||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
||||||
|
|
||||||
|
const handleXFeedback = useCallback(() => {
|
||||||
|
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
|
||||||
|
const tweetText = encodeURIComponent(`@nexmoex${versionText}`)
|
||||||
|
openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`)
|
||||||
|
}, [appVersion, openShareUrl])
|
||||||
|
|
||||||
|
const feedbackResources = useMemo<AboutResource[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
icon: Github,
|
||||||
|
label: t('about.resources.githubIssues'),
|
||||||
|
description: t('about.resources.githubIssuesDescription'),
|
||||||
|
actionLabel: t('about.actions.feedback'),
|
||||||
|
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Twitter,
|
||||||
|
label: t('about.resources.xFeedback'),
|
||||||
|
description: t('about.resources.xFeedbackDescription'),
|
||||||
|
actionLabel: t('about.actions.feedback'),
|
||||||
|
onClick: handleXFeedback
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MessageCircle,
|
||||||
|
label: t('about.resources.discord'),
|
||||||
|
description: t('about.resources.discordDescription'),
|
||||||
|
actionLabel: t('about.actions.visit'),
|
||||||
|
href: 'https://discord.gg/uBqXV6QPdm'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[t, handleXFeedback]
|
||||||
|
)
|
||||||
|
|
||||||
const aboutResources = useMemo<AboutResource[]>(
|
const aboutResources = useMemo<AboutResource[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -258,20 +313,6 @@ export function About() {
|
|||||||
description: t('about.resources.changelogDescription'),
|
description: t('about.resources.changelogDescription'),
|
||||||
actionLabel: t('about.actions.view'),
|
actionLabel: t('about.actions.view'),
|
||||||
href: 'https://github.com/nexmoe/VidBee/releases'
|
href: 'https://github.com/nexmoe/VidBee/releases'
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Github,
|
|
||||||
label: t('about.resources.githubIssues'),
|
|
||||||
description: t('about.resources.githubIssuesDescription'),
|
|
||||||
actionLabel: t('about.actions.feedback'),
|
|
||||||
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MessageCircle,
|
|
||||||
label: t('about.resources.discord'),
|
|
||||||
description: t('about.resources.discordDescription'),
|
|
||||||
actionLabel: t('about.actions.visit'),
|
|
||||||
href: 'https://discord.gg/uBqXV6QPdm'
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
@@ -282,69 +323,87 @@ export function About() {
|
|||||||
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
|
<img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
|
||||||
<div className="space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||||
</div>
|
<Badge variant="secondary">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{t('about.versionLabel', { version: appVersion })}
|
||||||
<Badge variant="secondary">
|
</Badge>
|
||||||
{t('about.versionLabel', { version: appVersion })}
|
{latestVersionState ? (
|
||||||
</Badge>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{latestVersionState ? (
|
{latestVersionBadgeText ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
||||||
{latestVersionBadgeText ? (
|
) : null}
|
||||||
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
{latestVersionStatusText ? (
|
||||||
) : null}
|
<span className={`text-sm ${latestVersionStatusClass}`}>
|
||||||
{latestVersionStatusText ? (
|
{latestVersionStatusText}
|
||||||
<span className={`text-sm ${latestVersionStatusClass}`}>
|
</span>
|
||||||
{latestVersionStatusText}
|
) : null}
|
||||||
</span>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{updateDownloadProgress !== null && (
|
|
||||||
<div className="space-y-2 w-full">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t('about.downloadingUpdate')}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{updateDownloadProgress.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={updateDownloadProgress} className="h-2" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href="https://github.com/nexmoe/vidbee"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={t('about.actions.openRepo')}
|
||||||
|
>
|
||||||
|
<Github className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{updateReady.ready ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleRestartToUpdate}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t('about.notifications.restartNowAction')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{latestVersionState?.status === 'available' ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleGoToDownload}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
{t('about.actions.goToDownload')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t('about.actions.checkUpdates')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="icon" asChild>
|
|
||||||
<a
|
|
||||||
href="https://github.com/nexmoe/vidbee"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
aria-label={t('about.actions.openRepo')}
|
|
||||||
>
|
|
||||||
<Github className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{latestVersionState?.status === 'available' ? (
|
|
||||||
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
{t('about.actions.goToDownload')}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button onClick={handleCheckForUpdates} className="gap-2">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
{t('about.actions.checkUpdates')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{updateDownloadProgress !== null && (
|
||||||
|
<div className="flex flex-col gap-3 pt-4">
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('about.downloadingUpdate')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{updateDownloadProgress.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={updateDownloadProgress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between gap-4 pt-6">
|
<div className="flex items-center justify-between gap-4 pt-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
|
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
|
||||||
@@ -361,7 +420,6 @@ export function About() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||||
<CardDescription>{t('about.shareDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -408,6 +466,45 @@ export function About() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
|
{/* Feedback section - merged into one row */}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
|
||||||
|
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium leading-none">{t('about.resources.feedback')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('about.resources.feedbackDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{feedbackResources.map((resource) => {
|
||||||
|
const Icon = resource.icon
|
||||||
|
return resource.href ? (
|
||||||
|
<Button key={resource.label} variant="outline" size="sm" asChild>
|
||||||
|
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{resource.label}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={resource.label}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={resource.onClick}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{resource.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Other resources */}
|
||||||
{aboutResources.map((resource) => {
|
{aboutResources.map((resource) => {
|
||||||
const Icon = resource.icon
|
const Icon = resource.icon
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@renderer/components/ui/select'
|
} from '@renderer/components/ui/select'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||||
|
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
||||||
import type { OneClickQualityPreset } from '@shared/types'
|
import type { OneClickQualityPreset } from '@shared/types'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
@@ -35,7 +36,7 @@ const clampSubscriptionInterval = (value: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n: i18nInstance } = useTranslation()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
@@ -131,6 +132,21 @@ export function Settings() {
|
|||||||
await handleSettingChange('theme', value)
|
await handleSettingChange('theme', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const languageOptions = languageList
|
||||||
|
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
|
||||||
|
const currentLanguage =
|
||||||
|
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
||||||
|
|
||||||
|
const handleLanguageChange = async (value: LanguageCode) => {
|
||||||
|
if (activeLanguageCode === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSetting({ key: 'language', value })
|
||||||
|
await i18nInstance.changeLanguage(value)
|
||||||
|
toast.success(t('notifications.settingsSaved'))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-background">
|
<div className="h-full bg-background">
|
||||||
<div className="container mx-auto max-w-4xl p-6 space-y-6">
|
<div className="container mx-auto max-w-4xl p-6 space-y-6">
|
||||||
@@ -185,6 +201,53 @@ export function Settings() {
|
|||||||
</Select>
|
</Select>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
|
<ItemSeparator />
|
||||||
|
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.language')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.languageDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Select
|
||||||
|
value={currentLanguage.value}
|
||||||
|
onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder={currentLanguage.name}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`${currentLanguage.flag} rounded-xs text-base`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{languageOptions.map((option) => {
|
||||||
|
const isActive = option.value === currentLanguage.value
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={isActive ? 'font-semibold bg-muted' : undefined}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`${option.flag} rounded-xs text-base`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span lang={option.hreflang}>{option.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
21
src/renderer/src/store/update.ts
Normal file
21
src/renderer/src/store/update.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { atom } from 'jotai'
|
||||||
|
|
||||||
|
type UpdateReadyState = {
|
||||||
|
ready: boolean
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateAvailableState = {
|
||||||
|
available: boolean
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateReadyAtom = atom<UpdateReadyState>({
|
||||||
|
ready: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateAvailableAtom = atom<UpdateAvailableState>({
|
||||||
|
available: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user