Compare commits

..

19 Commits

Author SHA1 Message Date
Nexmoe
0136da410d chore: release v1.1.6 2026-01-11 10:21:42 +08:00
Nexmoe
4971a5fe8a feat(settings): improve cookie profile handling (#74)
* feat(settings): improve cookie profile handling

* fix(settings): normalize profile input
2026-01-11 10:16:28 +08:00
Nexmoe
d2806164a2 feat(extension): integrate local video info API (#67)
* feat(extension): integrate local video info API

* chore(repo): clean up popup styles

* feat(extension): revamp popup UI

* chore(repo): revert docs and drop content script
2026-01-11 00:05:46 +08:00
Nexmoe
e332ec4ddc chore: release v1.1.5 2026-01-10 19:18:55 +08:00
Nexmoe
3b74712f57 fix(subscription): improve cover image selection (#73)
* fix(subscriptions): improve rss cover fallback

* fix(subscriptions): match img src first
2026-01-10 19:18:03 +08:00
Nexmoe
475e4127c2 fix(remote-image): add load timeout (#72) 2026-01-10 18:39:00 +08:00
Nexmoe
3041307aa2 fix: Advanced settings tab broken (#71) 2026-01-10 18:30:18 +08:00
Nexmoe
e2ab8dce60 chore: release v1.1.4 2026-01-09 21:39:01 +08:00
Nexmoe
c1806e5321 fix(main): hide window on autostart (#68) 2026-01-09 21:03:25 +08:00
Nexmoe
92494966c6 fix(settings): merge defaults for getAll (#66) 2026-01-09 20:53:08 +08:00
Nexmoe
f04680b8c2 chore: release v1.1.3 2026-01-02 16:17:42 +08:00
Nexmoe
a5b94411fe fix(download): handle empty format selections (#64) 2026-01-02 16:16:04 +08:00
Nexmoe
71ae4a4425 fix(download): add format fallback selectors (#63) 2026-01-01 14:41:59 +08:00
Nexmoe
e8413aefae feat(ui): show update indicator on About (#58)
* feat(ui): add update indicator for about

* fix(ci): remove unused import

* fix(ci): retry binary downloads
2025-12-26 16:46:55 +08:00
Nexmoe
b229225b6e chore: release v1.1.2 2025-12-26 13:04:52 +08:00
Nexmoe
5497ed6245 chore(templates): simplify bug report (#57) 2025-12-26 13:04:30 +08:00
Nexmoe
155bf652f2 fix(ci): harden download steps (#56) 2025-12-26 13:02:21 +08:00
Nexmoe
86bd77d995 fix(download): improve format fallbacks (#55) 2025-12-26 13:02:07 +08:00
Nexmoe
b38b356c22 feat(issue): add issue templates (#54) 2025-12-26 12:53:51 +08:00
69 changed files with 7027 additions and 521 deletions

37
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

View 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

View File

@@ -76,10 +76,10 @@ jobs:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
run: |
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
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
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
@@ -103,7 +103,11 @@ jobs:
shell: bash
run: |
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
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
@@ -112,7 +116,7 @@ jobs:
- name: Download yt-dlp binary
shell: bash
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
chmod +x "resources/${{ matrix.ytdlp_output }}"
fi
@@ -130,4 +134,3 @@ jobs:
name: dist-${{ matrix.os }}
path: dist/
retention-days: 1

2
.gitignore vendored
View File

@@ -2,6 +2,8 @@ node_modules
/dist
out
.conductor/
.wxt
.output
.DS_Store
.eslintcache
*.log*

View File

@@ -1,6 +1,6 @@
{
"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"
}
}

26
extension/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
extension/README.md Normal file
View File

@@ -0,0 +1,3 @@
# WXT + React
This template should help get you started developing with React in WXT.

View File

@@ -0,0 +1,163 @@
.vidbee-download-container {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 9999;
transition: all 0.2s ease;
overflow: visible;
}
.vidbee-download-container.vidbee-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0);
}
.vidbee-download-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
opacity: 0.6;
overflow: visible;
}
.vidbee-download-button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.7);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
.vidbee-download-button:active {
transform: scale(0.95);
}
.vidbee-download-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 16px;
height: 16px;
}
.vidbee-tooltip {
position: absolute;
right: calc(100% + 8px);
top: 50%;
padding: 6px 10px;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: white;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
transform: translateY(-50%) translateX(4px);
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
line-height: 1;
}
.vidbee-tooltip::after {
content: '';
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
border: 4px solid transparent;
border-left-color: rgba(0, 0, 0, 0.9);
}
.vidbee-download-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.vidbee-close-button {
position: absolute;
top: -6px;
right: -6px;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: rgba(255, 77, 77, 0.9);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
cursor: pointer;
font-size: 0;
transition: all 0.2s ease;
z-index: 1;
opacity: 0;
pointer-events: none;
overflow: visible;
}
.vidbee-download-container:hover .vidbee-close-button {
opacity: 1;
pointer-events: auto;
}
.vidbee-close-button:hover {
background: rgba(255, 77, 77, 1);
transform: scale(1.15);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
}
.vidbee-close-button:active {
transform: scale(0.9);
}
.vidbee-close-button svg {
flex-shrink: 0;
stroke: currentColor;
width: 10px;
height: 10px;
}
.vidbee-close-button .vidbee-tooltip {
right: calc(100% + 6px);
top: 50%;
left: auto;
transform: translateY(-50%) translateX(4px);
}
.vidbee-close-button .vidbee-tooltip::after {
left: 100%;
top: 50%;
right: auto;
transform: translateY(-50%);
border-left-color: rgba(0, 0, 0, 0.9);
border-top-color: transparent;
}
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
opacity: 1;
transform: translateY(-50%) translateX(0);
}

View File

@@ -0,0 +1,203 @@
type VideoFormat = {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
type VideoInfo = {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
type VideoInfoCacheEntry = {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
const PORT_RANGE_START = 27100
const PORT_RANGE_END = 27120
const STATUS_TIMEOUT_MS = 800
const INFO_TIMEOUT_MS = 60000
const CACHE_TTL_MS = 5 * 60 * 1000
const pendingRequests = new Map<string, Promise<void>>()
const defaultIconPaths = {
16: 'icon/16.png',
32: 'icon/32.png',
48: 'icon/48.png',
128: 'icon/128.png'
}
const loadingIconPaths = {
16: 'icon/icon-loading-16.png',
32: 'icon/icon-loading-32.png',
48: 'icon/icon-loading-48.png',
128: 'icon/icon-loading-128.png'
}
const successIconPaths = {
16: 'icon/icon-success-16.png',
32: 'icon/icon-success-32.png',
48: 'icon/icon-success-48.png',
128: 'icon/icon-success-128.png'
}
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
const paths =
status === 'loading'
? loadingIconPaths
: status === 'success'
? successIconPaths
: defaultIconPaths
const options = tabId ? { path: paths, tabId } : { path: paths }
void browser.action.setIcon(options)
}
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
try {
const response = await fetch(url, { signal: controller.signal })
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
if (!response.ok) {
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
const combined = [message, details].filter(Boolean).join('\n\n')
throw new Error(combined || `Request failed: ${response.status}`)
}
return data as T
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('Request timed out.')
}
if (error instanceof Error && error.message.includes('signal is aborted')) {
throw new Error('Request timed out.')
}
if (error instanceof Error && error.message.includes('Failed to fetch')) {
throw new Error('VidBee app not responding on this port.')
}
throw error
} finally {
clearTimeout(timeoutId)
}
}
const findAvailablePort = async (): Promise<number | null> => {
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
const baseUrl = `http://127.0.0.1:${port}`
try {
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
return port
} catch {
// Keep scanning.
}
}
return null
}
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
const port = await findAvailablePort()
if (!port) {
throw new Error('VidBee app not found on localhost.')
}
const baseUrl = `http://127.0.0.1:${port}`
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
if (!tokenResponse.token) {
throw new Error('Failed to acquire token from VidBee.')
}
return fetchJson<VideoInfo>(
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
tokenResponse.token
)}`,
INFO_TIMEOUT_MS
)
}
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) return {}
return map
}
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
const now = Date.now()
for (const [key, entry] of Object.entries(map)) {
if (now - entry.fetchedAt > CACHE_TTL_MS) {
delete map[key]
}
}
}
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const map = await getCacheMap()
pruneCache(map)
const cache = map[url]
if (!cache) return null
return cache
}
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
const map = await getCacheMap()
pruneCache(map)
map[cache.url] = cache
await browser.storage.local.set({ videoInfoCacheByUrl: map })
}
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
if (pendingRequests.has(url)) {
return pendingRequests.get(url) as Promise<void>
}
const task = (async () => {
const existing = await loadCache(url)
if (existing?.status === 'ready') {
setActionIcon('success', tabId)
return
}
setActionIcon('loading', tabId)
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
try {
const info = await requestVideoInfo(url)
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
setActionIcon('success', tabId)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
setActionIcon('default', tabId)
}
})()
pendingRequests.set(url, task)
try {
await task
} finally {
pendingRequests.delete(url)
}
}
export default defineBackground(() => {
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
if (message.type !== 'video-info:fetch' || !message.url) {
return
}
void fetchAndCache(message.url, sender.tab?.id)
})
})

View File

@@ -0,0 +1,291 @@
:root {
--bg: #ffffff;
--fg: #111111;
--fg-secondary: #757575;
--border: #f0f0f0;
--accent: #000000;
--error: #e00000;
--success: #00c853;
--warning: #ffd600;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: var(--bg);
color: var(--fg);
}
#root {
width: 360px;
min-height: 200px;
padding: 24px;
box-sizing: border-box;
}
.app {
display: flex;
flex-direction: column;
gap: 24px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 13px;
font-weight: 600;
margin: 0;
letter-spacing: -0.01em;
color: var(--fg);
}
.status-indicator {
font-size: 11px;
font-weight: 500;
color: var(--fg-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--border);
}
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
.status-dot.ok { background-color: var(--success); }
.status-dot.error { background-color: var(--error); }
.video-info {
display: grid;
grid-template-columns: 1fr 90px;
gap: 20px;
align-items: start;
}
.video-details h2 {
font-size: 15px;
font-weight: 500;
line-height: 1.4;
margin: 0 0 8px 0;
color: var(--fg);
}
.meta-row {
font-size: 12px;
color: var(--fg-secondary);
margin: 0 0 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.thumbnail {
width: 90px;
height: 50px;
background: var(--border);
object-fit: cover;
border-radius: 6px;
display: block;
}
.formats-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.format-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Sticky headers for long lists */
.sticky-title {
position: sticky;
top: 0;
background: var(--bg);
padding: 4px 0;
z-index: 10;
}
.group-title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-secondary);
font-weight: 600;
border-bottom: 2px solid var(--border);
padding-bottom: 4px;
}
.format-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.format-table th {
text-align: left;
font-weight: 400;
color: var(--fg-secondary);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
font-size: 10px;
text-transform: uppercase;
padding-top: 6px;
}
.format-table td {
padding: 6px 0;
border-bottom: 1px solid var(--border);
color: var(--fg);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.format-table tr:last-child td {
border-bottom: none;
}
.col-id { width: 50px; color: var(--fg-secondary); }
.col-ext { width: 50px; }
.col-size { width: 60px; text-align: right; }
.format-table th.col-size { text-align: right; }
.empty-state {
font-size: 12px;
color: var(--fg-secondary);
padding: 12px 0;
font-style: italic;
text-align: center;
}
.error-banner {
font-size: 12px;
color: var(--error);
line-height: 1.5;
background: rgba(224, 0, 0, 0.05);
padding: 12px;
border-radius: 6px;
}
.primary-button {
background: var(--fg);
color: var(--bg);
border: none;
border-radius: 8px;
padding: 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
width: 100%;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.primary-button:active {
transform: scale(0.98);
}
.error-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.troubleshoot-box {
background: var(--border);
padding: 12px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 8px;
}
.troubleshoot-title {
font-size: 11px;
font-weight: 600;
color: var(--fg-secondary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.troubleshoot-actions {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--accent);
text-decoration: none;
font-weight: 500;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
.link-button:hover {
text-decoration: underline;
}
.divider {
color: var(--fg-secondary);
}
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
gap: 16px;
flex: 1;
width: 100%;
}
.spinner {
width: 24px;
height: 24px;
border: 2.5px solid var(--border);
border-top-color: var(--fg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
font-size: 13px;
font-weight: 500;
color: var(--fg-secondary);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

View File

@@ -0,0 +1,415 @@
import { useEffect, useMemo, useState } from 'react'
import './App.css'
type VideoFormat = {
format_id?: string
ext?: string
format_note?: string
resolution?: string
width?: number
height?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
tbr?: number
}
type VideoInfo = {
title?: string
thumbnail?: string
duration?: number
formats?: VideoFormat[]
}
const CACHE_TTL_MS = 60 * 60 * 1000
const isValidHttpUrl = (value?: string): boolean => {
if (!value) return false
return value.startsWith('http://') || value.startsWith('https://')
}
const formatDuration = (value?: number): string => {
if (!value || value <= 0) return 'Unknown'
const totalSeconds = Math.round(value)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
const paddedSeconds = String(seconds).padStart(2, '0')
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
}
const formatBytes = (value?: number): string => {
if (!value || value <= 0) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
const isVideoFormat = (format: VideoFormat): boolean => {
if (format.vcodec && format.vcodec !== 'none') {
return true
}
return Boolean(format.resolution || format.width || format.height)
}
const isAudioFormat = (format: VideoFormat): boolean => {
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
}
type VideoInfoCacheEntry = {
url: string
status: 'pending' | 'ready' | 'error'
fetchedAt: number
info?: VideoInfo
error?: string
}
type VideoGroup = {
label: string
height: number
formats: VideoFormat[]
}
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
const data = await browser.storage.local.get('videoInfoCacheByUrl')
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
if (!map) return null
const cached = map[url]
if (!cached) return null
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
return cached
}
const sanitizeError = (error: string): string => {
const message = error.toLowerCase()
if (
message.includes('localhost') ||
message.includes('fetch') ||
message.includes('network') ||
message.includes('connect') ||
message.includes('failed to request')
) {
return 'Client connection failed'
}
return error
}
function App() {
const [info, setInfo] = useState<VideoInfo | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [currentUrl, setCurrentUrl] = useState<string>('')
const [retryTrigger, setRetryTrigger] = useState(0)
useEffect(() => {
let active = true
const targetState = { url: '' }
const handleStorageChange = (
changes: Record<string, browser.storage.StorageChange>,
areaName: string
) => {
if (!active || areaName !== 'local') return
const change = changes.videoInfoCacheByUrl
if (!change?.newValue) return
const map = change.newValue as Record<string, VideoInfoCacheEntry>
const next = map[targetState.url]
if (!next) return
if (next.status === 'ready' && next.info) {
setInfo(next.info)
setError(null)
setLoading(false)
} else if (next.status === 'error' && next.error) {
setError(sanitizeError(next.error))
setInfo(null)
setLoading(false)
} else if (next.status === 'pending') {
setLoading(true)
}
}
browser.storage.onChanged.addListener(handleStorageChange)
const loadInfo = async () => {
setLoading(true)
setError(null)
setInfo(null)
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
if (!isValidHttpUrl(tab?.url)) {
setError('Please open a valid video page first.')
setLoading(false)
return
}
const targetUrl = tab.url as string
targetState.url = targetUrl
setCurrentUrl(targetUrl)
const cached = await loadCachedInfo(targetUrl)
const shouldBypassCache = retryTrigger > 0
if (cached && !shouldBypassCache) {
if (cached.status === 'ready' && cached.info) {
setInfo(cached.info)
setLoading(false)
return
}
if (cached.status === 'error' && cached.error) {
setError(sanitizeError(cached.error))
setLoading(false)
return
}
}
try {
await browser.runtime.sendMessage({
type: 'video-info:fetch',
url: targetUrl
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to request video info.'
setError(sanitizeError(message))
setLoading(false)
}
const latest = await loadCachedInfo(targetUrl)
if (latest && latest.status === 'ready' && latest.info) {
setInfo(latest.info)
setError(null)
setLoading(false)
} else if (latest && latest.status === 'error' && latest.error) {
setError(sanitizeError(latest.error))
setInfo(null)
setLoading(false)
}
}
void loadInfo()
return () => {
active = false
browser.storage.onChanged.removeListener(handleStorageChange)
}
}, [retryTrigger])
const formats = useMemo(() => info?.formats ?? [], [info])
const groupedFormats = useMemo(() => {
const video: VideoFormat[] = []
const audio: VideoFormat[] = []
const other: VideoFormat[] = []
for (const format of formats) {
if (isVideoFormat(format)) {
video.push(format)
} else if (isAudioFormat(format)) {
audio.push(format)
} else {
other.push(format)
}
}
return { video, audio, other }
}, [formats])
const groupedVideoFormats = useMemo(() => {
const raw = groupedFormats.video
if (!raw.length) return []
const groups: Record<number, VideoFormat[]> = {}
const noHeight: VideoFormat[] = []
for (const f of raw) {
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
const heightVal = h ? Number(h) : 0
if (heightVal > 0) {
if (!groups[heightVal]) groups[heightVal] = []
groups[heightVal].push(f)
} else {
noHeight.push(f)
}
}
const sortedLabels = Object.keys(groups)
.map(Number)
.sort((a, b) => b - a)
const result: VideoGroup[] = sortedLabels.map((h) => ({
label: `${h}p`,
height: h,
formats: groups[h].sort((a, b) => {
const sa = a.filesize || a.filesize_approx || 0
const sb = b.filesize || b.filesize_approx || 0
return sb - sa
})
}))
if (noHeight.length > 0) {
result.push({
label: 'Other',
height: 0,
formats: noHeight
})
}
return result
}, [groupedFormats.video])
const handleOpenClient = () => {
if (!currentUrl) return
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
window.location.href = deepLink
}
const renderStatus = () => {
// if (loading) return null
if (error)
return (
<span className="status-indicator">
<div className="status-dot error" /> Error
</span>
)
if (info)
return (
<span className="status-indicator">
<div className="status-dot ok" /> Ready
</span>
)
return (
<span className="status-indicator">
<div className="status-dot" /> Idle
</span>
)
}
return (
<div className="app">
<header>
<h1>VidBee</h1>
{renderStatus()}
</header>
{loading && (
<div className="loading-container">
<div className="spinner" />
<div className="loading-text">Analyzing video...</div>
</div>
)}
{!loading && error && (
<div className="error-container">
<div className="error-banner">{error}</div>
<div className="troubleshoot-box">
<p className="troubleshoot-title">Having trouble?</p>
<div className="troubleshoot-actions">
<button
type="button"
className="link-button"
onClick={() => {
window.location.href = 'vidbee://'
setTimeout(() => setRetryTrigger((c) => c + 1), 100)
}}
>
Open Client
</button>
<span className="divider"></span>
<a
href="https://vidbee.app"
target="_blank"
rel="noopener noreferrer"
className="link-button"
>
Download
</a>
</div>
</div>
</div>
)}
{!loading && !error && info && (
<>
<section className="video-info">
<div className="video-details">
<h2>{info.title || 'Untitled video'}</h2>
<div className="meta-row">
<span>{formatDuration(info.duration)}</span>
<span></span>
<span>{formats.length} formats</span>
</div>
</div>
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
</section>
<button type="button" className="primary-button" onClick={handleOpenClient}>
Download with VidBee
</button>
<section className="formats-section">
{groupedVideoFormats.map((group) => (
<div className="format-group" key={group.label}>
<div className="group-title sticky-title">{group.label}</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{group.formats.map((f) => (
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
<div className="empty-state">No compatible formats.</div>
)}
{groupedFormats.audio.length > 0 && (
<div className="format-group">
<div className="group-title">Audio Only</div>
<table className="format-table">
<thead>
<tr>
<th className="col-id">ID</th>
<th className="col-ext">Ext</th>
<th className="col-size">Size</th>
</tr>
</thead>
<tbody>
{groupedFormats.audio.map((f) => (
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
<td className="col-id">{f.format_id || '-'}</td>
<td className="col-ext">{f.ext || '-'}</td>
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</>
)}
</div>
)
}
export default App

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
const root = document.getElementById('root')
if (root) {
ReactDOM.createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
}

28
extension/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "wxt-react-starter",
"description": "manifest.json description",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@wxt-dev/module-react": "^1.1.5",
"typescript": "^5.9.3",
"wxt": "^0.20.6"
}
}

3541
extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
{
"downloadWithVidBee": {
"message": "Download with VidBee"
},
"hideButton": {
"message": "Hide"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

7
extension/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx"
}
}

11
extension/wxt.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'wxt'
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
default_locale: 'en',
host_permissions: ['http://127.0.0.1/*'],
permissions: ['activeTab', 'storage']
}
})

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "1.1.1",
"version": "1.1.6",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
@@ -33,6 +33,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
@@ -47,6 +48,7 @@
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.44.7",
"electron-ipc-decorator": "^0.2.0",

60
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-label':
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)
'@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':
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)
@@ -80,6 +83,9 @@ importers:
clsx:
specifier: ^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:
specifier: ^1.11.18
version: 1.11.18
@@ -999,6 +1005,19 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -1896,6 +1915,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
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:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -4481,6 +4506,29 @@ snapshots:
'@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)':
dependencies:
'@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: {}
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:
dependencies:
color-name: 1.1.4

View File

@@ -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) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
protocol
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// 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) => {
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
fs.unlinkSync(dest)
reject(err)
safeUnlink(dest)
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) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
@@ -299,7 +339,7 @@ async function downloadYtDlp(config) {
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
try {
await downloadFile(url, tempPath)
await downloadFileWithRetry(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
@@ -342,7 +382,7 @@ async function downloadFfmpegWindows(config) {
}
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -397,7 +437,7 @@ async function downloadFfmpegMac(config) {
}
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -451,7 +491,7 @@ async function downloadFfmpegLinux(config) {
}
try {
await downloadFile(downloadUrl, tempTar)
await downloadFileWithRetry(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
@@ -498,7 +538,7 @@ async function downloadDenoRuntime() {
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting Deno runtime...', 'info')
extractZip(tempZip, extractDir)

View File

@@ -21,6 +21,10 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
const format = options.format
const audioFormat = options.audioFormat
if (format && audioFormat === '') {
return format
}
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
@@ -30,8 +34,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
return 'bestvideo+bestaudio'
// Prefer merged formats, but allow single-file "best" for sites without separate streams.
return 'bestvideo+bestaudio/best'
}
return `bestvideo+${audioFormat}`
}

View File

@@ -2,7 +2,14 @@ import { existsSync } from 'node:fs'
import { isAbsolute, join, relative, resolve } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron'
import {
app,
BrowserWindow,
type BrowserWindowConstructorOptions,
ipcMain,
protocol,
shell
} from 'electron'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
@@ -13,6 +20,7 @@ import { ffmpegManager } from './lib/ffmpeg-manager'
import { subscriptionManager } from './lib/subscription-manager'
import { subscriptionScheduler } from './lib/subscription-scheduler'
import { ytdlpManager } from './lib/ytdlp-manager'
import { startExtensionApiServer, stopExtensionApiServer } from './local-api'
import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
import { applyAutoLaunchSetting } from './utils/auto-launch'
@@ -129,6 +137,7 @@ subscriptionManager.on('subscriptions:updated', (subscriptions) => {
export function createWindow(): void {
const isMac = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin
const windowOptions: BrowserWindowConstructorOptions = {
width: 1200,
@@ -168,6 +177,9 @@ export function createWindow(): void {
})
mainWindow.on('ready-to-show', () => {
if (shouldStartHidden) {
return
}
mainWindow?.show()
})
@@ -190,10 +202,48 @@ export function createWindow(): void {
flushPendingDeepLinks()
})
// Setup error handling for renderer process
setupRendererErrorHandling()
// Setup download engine event forwarding to renderer
setupDownloadEvents()
}
function setupRendererErrorHandling(): void {
if (!mainWindow) return
// Handle uncaught exceptions in renderer process
mainWindow.webContents.on('unresponsive', () => {
log.error('Renderer process became unresponsive')
})
mainWindow.webContents.on('responsive', () => {
log.info('Renderer process became responsive again')
})
// Listen for renderer errors via IPC
ipcMain.on('error:renderer', (_event, errorData) => {
log.error('Renderer error received:', errorData)
// Log detailed error information
if (errorData.error) {
log.error('Error name:', errorData.error.name)
log.error('Error message:', errorData.error.message)
if (errorData.error.stack) {
log.error('Error stack:', errorData.error.stack)
}
}
if (errorData.errorInfo?.componentStack) {
log.error('Component stack:', errorData.errorInfo.componentStack)
}
if (errorData.context) {
log.error('Error context:', errorData.context)
}
})
}
function setupDownloadEvents(): void {
downloadEngine.on('download-started', (id: string) => {
mainWindow?.webContents.send('download:started', id)
@@ -379,6 +429,17 @@ app.whenReady().then(async () => {
// and ignore CommandOrControl + R in production.
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
// Enable F12 to toggle DevTools in both development and production
window.webContents.on('before-input-event', (_, input) => {
if (input.key === 'F12') {
if (window.webContents.isDevToolsOpened()) {
window.webContents.closeDevTools()
} else {
window.webContents.openDevTools()
}
}
})
})
// IPC services are automatically registered by electron-ipc-decorator when imported
@@ -402,6 +463,8 @@ app.whenReady().then(async () => {
log.error('Failed to initialize yt-dlp:', error)
}
await startExtensionApiServer()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
@@ -449,6 +512,7 @@ app.on('window-all-closed', () => {
// Cleanup tray on quit
app.on('will-quit', () => {
destroyTray()
void stopExtensionApiServer()
})
// In this file you can include the rest of your app's specific main process

View File

@@ -1,5 +1,6 @@
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
import { AppService } from './services/app-service'
import { BrowserCookiesService } from './services/browser-cookies-service'
import { DownloadService } from './services/download-service'
import { FileSystemService } from './services/file-system-service'
import { HistoryService } from './services/history-service'
@@ -12,6 +13,7 @@ import { WindowService } from './services/window-service'
// Create services with automatic type inference
export const services = createServices([
AppService,
BrowserCookiesService,
DownloadService,
FileSystemService,
HistoryService,

View File

@@ -0,0 +1,270 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { resolvePathWithHome } from '../../utils/path-helpers'
class BrowserCookiesService extends IpcService {
static readonly groupName = 'browserCookies'
private buildValidationResult(valid: boolean, reason?: string) {
if (valid) {
return { valid }
}
return { valid, reason }
}
private isDirectory(target: string): boolean {
try {
return fs.statSync(target).isDirectory()
} catch {
return false
}
}
private pickFirstDirectory(paths: string[]): string {
for (const candidate of paths) {
if (this.isDirectory(candidate)) {
return candidate
}
}
return ''
}
private normalizeProfileInput(value: string): string {
return value.trim().replace(/^['"]|['"]$/g, '')
}
private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {
if (platform === 'win32') {
if (browser === 'edge') {
return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]
}
if (browser === 'chrome') {
return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]
}
if (browser === 'chromium') {
return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]
}
if (browser === 'brave') {
return [
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')
]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]
}
if (browser === 'whale') {
return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]
}
if (browser === 'opera') {
return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]
}
if (browser === 'firefox') {
return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]
}
}
if (platform === 'darwin') {
if (browser === 'edge') {
return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]
}
if (browser === 'chrome') {
return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]
}
if (browser === 'chromium') {
return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]
}
if (browser === 'brave') {
return [
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')
]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]
}
if (browser === 'whale') {
return [
path.join(homeDir, 'Library', 'Application Support', 'Whale'),
path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')
]
}
if (browser === 'opera') {
return [
path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),
path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')
]
}
if (browser === 'firefox') {
return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]
}
if (browser === 'safari') {
return [path.join(homeDir, 'Library', 'Safari')]
}
}
if (platform === 'linux') {
if (browser === 'edge') {
return [path.join(homeDir, '.config', 'microsoft-edge')]
}
if (browser === 'chrome') {
return [path.join(homeDir, '.config', 'google-chrome')]
}
if (browser === 'chromium') {
return [path.join(homeDir, '.config', 'chromium')]
}
if (browser === 'brave') {
return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]
}
if (browser === 'vivaldi') {
return [path.join(homeDir, '.config', 'vivaldi')]
}
if (browser === 'whale') {
return [path.join(homeDir, '.config', 'naver-whale')]
}
if (browser === 'opera') {
return [path.join(homeDir, '.config', 'opera')]
}
if (browser === 'firefox') {
return [path.join(homeDir, '.mozilla', 'firefox')]
}
}
if (platform === 'freebsd') {
if (browser === 'firefox') {
return [path.join(homeDir, '.mozilla', 'firefox')]
}
}
return []
}
private getDefaultProfilePath(baseDirs: string[], browser: string): string {
const base = baseDirs[0]
if (!base) {
return ''
}
if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {
return base
}
return path.join(base, 'Default')
}
private findFirefoxProfilePath(profilesDir: string): string {
if (!this.isDirectory(profilesDir)) {
return ''
}
const entries = fs
.readdirSync(profilesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b))
const preferred =
entries.find((name) => name.endsWith('.default-release')) ??
entries.find((name) => name.endsWith('.default')) ??
entries[0]
return preferred ? path.join(profilesDir, preferred) : ''
}
@IpcMethod()
getBrowserProfilePath(_context: IpcContext, browser: string): string {
if (!browser || browser === 'none') {
return ''
}
const homeDir = os.homedir()
const platform = os.platform()
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)
if (browser === 'firefox') {
const profilesDir = baseDirs[0]
const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''
return profilePath || fallbackPath
}
if (browser === 'safari') {
const safariPath = baseDirs[0]
if (safariPath && this.isDirectory(safariPath)) {
return safariPath
}
return fallbackPath
}
if (baseDirs.length === 0) {
return fallbackPath
}
let detectedPath = ''
for (const baseDir of baseDirs) {
if (!baseDir) {
continue
}
const candidates =
browser === 'opera'
? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
: [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
detectedPath = this.pickFirstDirectory(candidates)
if (detectedPath) {
break
}
}
return detectedPath || fallbackPath
}
@IpcMethod()
validateBrowserProfilePath(
_context: IpcContext,
browser: string,
profilePath: string
): { valid: boolean; reason?: string } {
if (!browser || browser === 'none') {
return this.buildValidationResult(false, 'browserUnsupported')
}
const normalizedInput = this.normalizeProfileInput(profilePath)
if (!normalizedInput) {
return this.buildValidationResult(false, 'empty')
}
const resolvedInput = resolvePathWithHome(normalizedInput)
if (resolvedInput && this.isDirectory(resolvedInput)) {
return this.buildValidationResult(true)
}
const looksLikePath =
resolvedInput &&
(path.isAbsolute(resolvedInput) ||
resolvedInput.includes('/') ||
resolvedInput.includes('\\'))
if (looksLikePath) {
return this.buildValidationResult(false, 'pathNotFound')
}
const platform = os.platform()
const homeDir = os.homedir()
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
if (baseDirs.length === 0) {
return this.buildValidationResult(false, 'browserUnsupported')
}
for (const baseDir of baseDirs) {
if (!baseDir) {
continue
}
const candidate = path.join(baseDir, normalizedInput)
if (this.isDirectory(candidate)) {
return this.buildValidationResult(true)
}
}
return this.buildValidationResult(false, 'profileNotFound')
}
}
export { BrowserCookiesService }

View File

@@ -1,6 +1,5 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { AppSettings } from '../../../shared/types'
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
import { settingsManager } from '../../settings'
import { updateTrayMenu } from '../../tray'
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
@@ -29,10 +28,6 @@ class SettingsService extends IpcService {
if (key === 'launchAtLogin') {
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
}
if (key === 'subscriptionCheckIntervalHours') {
subscriptionScheduler.refreshInterval()
}
}
@IpcMethod()
@@ -55,10 +50,6 @@ class SettingsService extends IpcService {
if (typeof settings.launchAtLogin === 'boolean') {
applyAutoLaunchSetting(settings.launchAtLogin)
}
if (settings.subscriptionCheckIntervalHours !== undefined) {
subscriptionScheduler.refreshInterval()
}
}
@IpcMethod()
@@ -66,7 +57,6 @@ class SettingsService extends IpcService {
settingsManager.reset()
applyDockVisibility(settingsManager.get('hideDockIcon'))
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
subscriptionScheduler.refreshInterval()
}
}

View File

@@ -36,6 +36,19 @@ interface DownloadProcess {
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 => {
if (!dir) {
return
@@ -732,6 +745,8 @@ class DownloadEngine extends EventEmitter {
args.push('--ffmpeg-location', ffmpegPath)
args.push(urlArg)
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
signal: controller.signal

View File

@@ -19,6 +19,11 @@ type ParserItem = {
isoDate?: string
pubDate?: string
youtubeId?: string
content?: string
contentSnippet?: string
contentEncoded?: string
summary?: string
description?: string
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
mediaContent?: Array<{ url?: string }> | { url?: string }
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
@@ -41,24 +46,19 @@ type FeedItem = {
thumbnail?: string
}
const parser = new Parser<{ item: ParserItem }>({
const parser = new Parser<Record<string, never>, ParserItem>({
customFields: {
item: [
['yt:videoId', 'youtubeId'],
['media:thumbnail', 'mediaThumbnail'],
['media:content', 'mediaContent'],
['enclosure', 'enclosure']
['enclosure', 'enclosure'],
['content:encoded', 'contentEncoded'],
['description', 'description']
]
}
})
const clampIntervalHours = (value: number | undefined): number => {
if (!value || Number.isNaN(value)) {
return 3
}
return Math.min(24, Math.max(1, value))
}
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
return `sub_${base}`
@@ -167,7 +167,7 @@ export class SubscriptionScheduler extends EventEmitter {
if (this.timer) {
clearTimeout(this.timer)
}
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
const intervalHours = 3 // Default check interval: 3 hours
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
this.timer = setTimeout(() => {
void this.checkAll().finally(() => this.scheduleNextRun())
@@ -240,13 +240,18 @@ export class SubscriptionScheduler extends EventEmitter {
}
const latestItem = normalizedItems[0]
const coverUrl = this.resolveSubscriptionCover(
feed,
normalizedItems,
feedItems as ParserItem[]
)
subscriptionManager.update(subscription.id, {
status: 'up-to-date',
lastSuccessAt: Date.now(),
lastError: undefined,
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
coverUrl: coverUrl ?? subscription.coverUrl,
title:
typeof feed.title === 'string' && feed.title.trim().length > 0
? feed.title.trim()
@@ -366,6 +371,74 @@ export class SubscriptionScheduler extends EventEmitter {
return mediaContent.url as string | undefined
}
// Try to parse an image from HTML fields
const htmlCandidates = [
item.content,
item.contentEncoded,
item.description,
item.summary,
item.contentSnippet
]
for (const html of htmlCandidates) {
const imageUrl = this.extractImageFromHtml(html)
if (imageUrl) {
return imageUrl
}
}
return undefined
}
private resolveSubscriptionCover(
feed: Parser.Output<ParserItem>,
items: FeedItem[],
rawItems: ParserItem[]
): string | undefined {
const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined
if (feedImageUrl) {
return feedImageUrl
}
const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined
if (itunesImageUrl) {
return itunesImageUrl
}
const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail
if (itemThumbnail) {
return itemThumbnail
}
for (const item of rawItems) {
const thumbnail = this.resolveThumbnail(item)
if (thumbnail) {
return thumbnail
}
}
return undefined
}
private extractImageFromHtml(html?: string): string | undefined {
if (!html) {
return undefined
}
const srcMatch = html.match(
/<img\b[^>]*\b(?:src|data-src|data-original)\b\s*=\s*(['"]?)([^'">\s]+)\1/i
)
if (srcMatch?.[2]) {
return srcMatch[2]
}
const srcsetMatch = html.match(/<img[^>]+srcset\s*=\s*(['"])([^'"]+)\1/i)
if (srcsetMatch?.[2]) {
const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\s+/)[0]
if (firstCandidate) {
return firstCandidate
}
}
return undefined
}

191
src/main/local-api.ts Normal file
View File

@@ -0,0 +1,191 @@
import crypto from 'node:crypto'
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import log from 'electron-log/main'
import { downloadEngine } from './lib/download-engine'
const PORT_RANGE_START = 27100
const PORT_RANGE_END = 27120
const TOKEN_TTL_MS = 60_000
type TokenRecord = {
expiresAt: number
}
let server: http.Server | null = null
let serverPort: number | null = null
const tokens = new Map<string, TokenRecord>()
const isLoopbackAddress = (address?: string | null): boolean => {
if (!address) return false
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
}
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end(JSON.stringify(body))
}
const writeEmpty = (res: http.ServerResponse, status: number): void => {
res.writeHead(status, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end()
}
const issueToken = (): string => {
const token = crypto.randomBytes(16).toString('hex')
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
return token
}
const consumeToken = (token?: string | null): boolean => {
if (!token) return false
const record = tokens.get(token)
if (!record) return false
if (Date.now() > record.expiresAt) {
tokens.delete(token)
return false
}
tokens.delete(token)
return true
}
const handleRequest = async (
req: http.IncomingMessage,
res: http.ServerResponse
): Promise<void> => {
try {
if (!isLoopbackAddress(req.socket.remoteAddress)) {
writeJson(res, 403, { error: 'Forbidden' })
return
}
if (req.method === 'OPTIONS') {
writeEmpty(res, 204)
return
}
if (!req.url) {
writeJson(res, 400, { error: 'Missing URL' })
return
}
const requestUrl = new URL(req.url, 'http://127.0.0.1')
const pathname = requestUrl.pathname
if (req.method !== 'GET') {
writeJson(res, 405, { error: 'Method not allowed' })
return
}
if (pathname === '/token') {
const token = issueToken()
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
return
}
if (pathname === '/video-info') {
const token = requestUrl.searchParams.get('token')
if (!consumeToken(token)) {
writeJson(res, 401, { error: 'Invalid token' })
return
}
const targetUrl = requestUrl.searchParams.get('url')
if (!targetUrl || !targetUrl.trim()) {
writeJson(res, 400, { error: 'Missing url' })
return
}
try {
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
writeJson(res, 200, {
title: info.title,
thumbnail: info.thumbnail,
duration: info.duration,
formats: info.formats ?? []
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
const details =
error instanceof Error
? error.stack
: typeof error === 'object' && error && 'stderr' in error
? String((error as { stderr?: unknown }).stderr ?? '')
: undefined
writeJson(res, 500, { error: message, details })
}
return
}
if (pathname === '/status') {
writeJson(res, 200, { ok: true })
return
}
writeJson(res, 404, { error: 'Not found' })
} catch (error) {
const message = error instanceof Error ? error.message : 'Unhandled request error'
writeJson(res, 500, { error: message })
}
}
const startServerOnPort = (port: number): Promise<http.Server> =>
new Promise((resolve, reject) => {
const httpServer = http.createServer((req, res) => {
void handleRequest(req, res)
})
httpServer.once('error', (error) => {
httpServer.close()
reject(error)
})
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
})
export async function startExtensionApiServer(): Promise<number | null> {
if (server && serverPort) {
return serverPort
}
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
try {
server = await startServerOnPort(port)
const address = server.address() as AddressInfo | null
serverPort = address?.port ?? port
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
return serverPort
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err.code !== 'EADDRINUSE') {
log.warn('Extension API failed to start on port:', port, err)
}
}
}
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
return null
}
export async function stopExtensionApiServer(): Promise<void> {
if (!server) return
await new Promise<void>((resolve) => {
server?.close(() => resolve())
})
server = null
serverPort = null
tokens.clear()
}

View File

@@ -53,7 +53,11 @@ class SettingsManager {
}
getAll(): AppSettings {
return this.store.store
return {
...defaultSettings,
downloadPath: DEFAULT_DOWNLOAD_PATH,
...this.store.store
}
}
setAll(settings: Partial<AppSettings>): void {

View File

@@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { toast } from 'sonner'
import { ErrorBoundary } from './components/error/ErrorBoundary'
import { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About'
import { Home } from './pages/Home'
@@ -16,6 +17,7 @@ import { Settings } from './pages/Settings'
import { Subscriptions } from './pages/Subscriptions'
import { loadSettingsAtom, settingsAtom } from './store/settings'
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
import { updateAvailableAtom, updateReadyAtom } from './store/update'
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
@@ -51,6 +53,8 @@ function AppContent() {
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
const [settings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const setUpdateReady = useSetAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
@@ -174,8 +178,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()
setUpdateReady({
ready: true,
version: info.version
})
setUpdateAvailable({
available: true,
version: info.version
})
}
const handleUpdateError = (rawMessage: unknown) => {
@@ -211,19 +232,21 @@ function AppContent() {
}
// 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:error', handleUpdateError)
ipcEvents.on('update:download-progress', handleDownloadProgress)
ipcEvents.on('update:show-notification', handleUpdateNotification)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [t])
}, [setUpdateAvailable, setUpdateReady, t])
return (
<div className="flex flex-row h-screen">
@@ -270,11 +293,13 @@ function AppContent() {
function App() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<HashRouter>
<AppContent />
</HashRouter>
</ThemeProvider>
<ErrorBoundary>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<HashRouter>
<AppContent />
</HashRouter>
</ThemeProvider>
</ErrorBoundary>
)
}

View File

@@ -78,11 +78,11 @@ const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio']
return dedupe(['worstaudio', 'bestaudio'])
}
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'])
}
@@ -90,8 +90,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
return 'worstvideo+worstaudio'
// Prefer separate streams, then fall back to single-file selectors.
return 'worstvideo+worstaudio/worst/best'
}
const maxHeight = qualityPresetToVideoHeight[preset]
@@ -114,16 +114,18 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
combinations.push(video)
}
} 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('best')
return dedupe(combinations).join('/')
}
const buildAudioFormatPreference = (settings: AppSettings): string => {
const selectors = buildAudioSelectors(getQualityPreset(settings))
return selectors.join('/')
return dedupe([...selectors, 'best']).join('/')
}
interface DownloadDialogProps {
@@ -788,11 +790,12 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
type,
format:
type === 'video'
? videoInfoCardState.selectedVideoFormat
? videoInfoCardState.selectedVideoFormat || undefined
: type === 'extract'
? undefined
: videoInfoCardState.selectedAudioFormat,
audioFormat: type === 'video' ? videoInfoCardState.selectedAudioForVideo : undefined,
: videoInfoCardState.selectedAudioFormat || undefined,
audioFormat:
type === 'video' ? videoInfoCardState.selectedAudioForVideo || undefined : undefined,
extractFormat:
type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined,
extractQuality:
@@ -935,29 +938,33 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
{t('sites.viewAll')}
</Button>
</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>
{/* 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 && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3">

View File

@@ -0,0 +1,152 @@
import { ipcServices } from '@renderer/lib/ipc'
import { logger } from '@renderer/lib/logger'
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { type ErrorInfo as ErrorInfoType, ErrorPage } from './ErrorPage'
interface Props {
children: ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
fallback?: (errorInfo: ErrorInfoType) => ReactNode
}
interface State {
hasError: boolean
errorInfo: ErrorInfoType | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
errorInfo: null
}
}
static getDerivedStateFromError(error: Error): Partial<State> {
const errorInfo = {
error,
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform
}
}
// Log error details immediately
logger.error('ErrorBoundary: getDerivedStateFromError called', {
errorName: error.name,
errorMessage: error.message,
errorStack: error.stack,
url: errorInfo.context.url,
timestamp: errorInfo.timestamp
})
return {
hasError: true,
errorInfo
}
}
async componentDidCatch(error: Error, errorInfo: ErrorInfo): Promise<void> {
logger.error('ErrorBoundary caught an error:', {
errorName: error.name,
errorMessage: error.message,
errorStack: error.stack,
componentStack: errorInfo.componentStack,
errorInfo: JSON.stringify(errorInfo, null, 2)
})
// Get app version if available
let appVersion: string | undefined
try {
if (window?.api && ipcServices?.app) {
appVersion = await ipcServices.app.getVersion()
logger.info('ErrorBoundary: App version retrieved', { appVersion })
}
} catch (err) {
logger.warn('Failed to get app version:', err)
}
// Update state with component stack and version
if (this.state.errorInfo) {
this.setState({
errorInfo: {
...this.state.errorInfo,
context: {
...this.state.errorInfo.context,
version: appVersion
},
errorInfo: {
componentStack: errorInfo.componentStack || undefined
}
}
})
}
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo)
}
// Send error to main process if available
if (window?.api) {
try {
window.api.send('error:renderer', {
error: {
name: error.name,
message: error.message,
stack: error.stack
},
errorInfo: {
componentStack: errorInfo.componentStack
},
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
version: appVersion
}
})
} catch (err) {
logger.error('Failed to send error to main process:', err)
}
}
}
handleReload = (): void => {
this.setState({
hasError: false,
errorInfo: null
})
window.location.reload()
}
handleGoHome = (): void => {
this.setState({
hasError: false,
errorInfo: null
})
window.location.hash = '/'
window.location.reload()
}
render(): ReactNode {
if (this.state.hasError && this.state.errorInfo) {
if (this.props.fallback) {
return this.props.fallback(this.state.errorInfo)
}
return (
<ErrorPage
errorInfo={this.state.errorInfo}
onReload={this.handleReload}
onGoHome={this.handleGoHome}
/>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,200 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Textarea } from '@renderer/components/ui/textarea'
import { logger } from '@renderer/lib/logger'
import { AlertTriangle, Copy, Home, RefreshCw } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
export interface ErrorInfo {
error: Error
errorInfo?: {
componentStack?: string
}
timestamp: number
context?: {
url?: string
userAgent?: string
platform?: string
version?: string
}
}
interface ErrorPageProps {
errorInfo: ErrorInfo
onReload?: () => void
onGoHome?: () => void
}
export function ErrorPage({ errorInfo, onReload, onGoHome }: ErrorPageProps) {
const { t } = useTranslation()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
const errorReport = generateErrorReport(errorInfo)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(errorReport)
setCopied(true)
toast.success(t('error.copySuccess'))
setTimeout(() => setCopied(false), 2000)
} catch (error) {
logger.error('Failed to copy error report:', error)
toast.error(t('error.copyFailed'))
}
}
const handleReload = () => {
if (onReload) {
onReload()
} else {
window.location.reload()
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-background p-4">
<Card className="w-full max-w-3xl">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<AlertTriangle className="h-8 w-8 text-destructive" />
</div>
<div className="flex-1">
<CardTitle className="text-2xl">{t('error.title')}</CardTitle>
<CardDescription className="mt-2">{t('error.description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Error Message */}
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<p className="text-sm font-medium text-destructive mb-1">{t('error.message')}</p>
<p className="text-sm text-foreground break-words">
{errorInfo.error.message || t('error.unknownError')}
</p>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2">
{onGoHome && (
<Button variant="outline" onClick={onGoHome}>
<Home className="h-4 w-4 mr-2" />
{t('error.goHome')}
</Button>
)}
<Button variant="outline" onClick={handleReload}>
<RefreshCw className="h-4 w-4 mr-2" />
{t('error.reload')}
</Button>
<Button variant="outline" onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
{copied ? t('error.copied') : t('error.copyReport')}
</Button>
<Button variant="ghost" onClick={() => setShowDetails(!showDetails)}>
{showDetails ? t('error.hideDetails') : t('error.showDetails')}
</Button>
</div>
{/* Error Details */}
{showDetails && (
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">{t('error.stackTrace')}</p>
<ScrollArea className="h-48 rounded-md border bg-muted/50 p-4">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{errorInfo.error.stack || t('error.noStackTrace')}
</pre>
</ScrollArea>
</div>
{errorInfo.errorInfo?.componentStack && (
<div>
<p className="text-sm font-medium mb-2">{t('error.componentStack')}</p>
<ScrollArea className="h-32 rounded-md border bg-muted/50 p-4">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{errorInfo.errorInfo.componentStack}
</pre>
</ScrollArea>
</div>
)}
<div>
<p className="text-sm font-medium mb-2">{t('error.fullReport')}</p>
<Textarea
readOnly
value={errorReport}
className="font-mono text-xs min-h-48"
onClick={(e) => {
const target = e.target as HTMLTextAreaElement
target.select()
}}
/>
</div>
</div>
)}
{/* Help Text */}
<div className="rounded-md bg-muted/50 border p-4">
<p className="text-sm text-muted-foreground">{t('error.helpText')}</p>
</div>
</CardContent>
</Card>
</div>
)
}
function generateErrorReport(errorInfo: ErrorInfo): string {
const lines: string[] = []
lines.push('=== VidBee Error Report ===')
lines.push(`Timestamp: ${new Date(errorInfo.timestamp).toISOString()}`)
lines.push('')
if (errorInfo.context) {
lines.push('--- Context ---')
if (errorInfo.context.version) {
lines.push(`App Version: ${errorInfo.context.version}`)
}
if (errorInfo.context.platform) {
lines.push(`Platform: ${errorInfo.context.platform}`)
}
if (errorInfo.context.url) {
lines.push(`URL: ${errorInfo.context.url}`)
}
if (errorInfo.context.userAgent) {
lines.push(`User Agent: ${errorInfo.context.userAgent}`)
}
lines.push('')
}
lines.push('--- Error ---')
lines.push(`Name: ${errorInfo.error.name}`)
lines.push(`Message: ${errorInfo.error.message}`)
lines.push('')
if (errorInfo.error.stack) {
lines.push('--- Stack Trace ---')
lines.push(errorInfo.error.stack)
lines.push('')
}
if (errorInfo.errorInfo?.componentStack) {
lines.push('--- Component Stack ---')
lines.push(errorInfo.errorInfo.componentStack)
lines.push('')
}
lines.push('=== End of Report ===')
return lines.join('\n')
}

View 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 }

View File

@@ -67,6 +67,8 @@ interface RemoteImageProps {
* />
* ```
*/
const IMAGE_LOAD_TIMEOUT_MS = 30000
export function RemoteImage({
src,
alt,
@@ -91,8 +93,10 @@ export function RemoteImage({
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
const [isImageLoading, setIsImageLoading] = useState(true)
const [timedOutSrc, setTimedOutSrc] = useState<string | null>(null)
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
const isLoading = isCacheLoading || isImageLoading
const hasTimedOut = Boolean(src) && timedOutSrc === src
const isLoading = !hasTimedOut && (isCacheLoading || isImageLoading)
useEffect(() => {
if (imageSrc) {
@@ -102,6 +106,19 @@ export function RemoteImage({
}
}, [imageSrc])
useEffect(() => {
if (!src || hasTimedOut || !isLoading) return
const timeoutId = window.setTimeout(() => {
setTimedOutSrc(src)
setIsImageLoading(false)
}, IMAGE_LOAD_TIMEOUT_MS)
return () => {
window.clearTimeout(timeoutId)
}
}, [src, hasTimedOut, isLoading])
useEffect(() => {
onLoadingChange?.(isLoading)
}, [isLoading, onLoadingChange])

View File

@@ -1,22 +1,13 @@
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 { saveSettingAtom } from '@renderer/store/settings'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import { useSetAtom } from 'jotai'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import '../../assets/title-bar.css'
import { updateAvailableAtom } from '@renderer/store/update'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
import MingcuteInformationFill from '~icons/mingcute/information-fill'
import MingcuteInformationLine from '~icons/mingcute/information-line'
import MingcuteRssFill from '~icons/mingcute/rss-fill'
@@ -53,10 +44,8 @@ interface SidebarProps {
}
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
const { t, i18n } = useTranslation()
const saveSetting = useSetAtom(saveSettingAtom)
const languageOptions = languageList
const { t } = useTranslation()
const [updateAvailable] = useAtom(updateAvailableAtom)
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 isActive = item.id !== 'supported-sites' && currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
@@ -161,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
<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 */}
{bottomNavigationItems.map((item) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
const showUpdateDot = item.id === 'about' && updateAvailable.available
return (
<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"
size="icon"
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' : ''}`} />
{showUpdateDot ? (
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent side="right">

View File

@@ -73,23 +73,31 @@ export function FormatSelector({
useEffect(() => {
// Filter and sort formats
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
const videos = formats.filter(
(f) =>
f.video_ext !== 'none' &&
f.vcodec &&
f.vcodec !== 'none' &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
const isVideoFormat = (format: VideoFormat) =>
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
const isAudioFormat = (format: VideoFormat) =>
format.acodec &&
format.acodec !== 'none' &&
(format.video_ext === 'none' || !format.video_ext)
const isHlsFormat = (format: VideoFormat) =>
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
const videoCandidates = formats.filter(
(format) => isVideoFormat(format) && !isHlsFormat(format)
)
const audios = formats.filter(
(f) =>
f.acodec &&
f.acodec !== 'none' &&
(f.video_ext === 'none' || !f.video_ext) &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
const audioCandidates = formats.filter(
(format) => isAudioFormat(format) && !isHlsFormat(format)
)
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
const filteredVideos = settings.showMoreFormats
? videos
@@ -99,6 +107,9 @@ export function FormatSelector({
? audios
: 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)
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by height (higher is better)
@@ -138,23 +149,34 @@ export function FormatSelector({
return 0
}
filteredVideos.sort(sortVideoFormatsByQuality)
filteredAudios.sort(sortAudioFormatsByQuality)
finalVideos.sort(sortVideoFormatsByQuality)
finalAudios.sort(sortAudioFormatsByQuality)
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
setVideoFormats(finalVideos)
setAudioFormats(finalAudios)
// Auto-select best format based on preferences
if (filteredVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
// Auto-select best format based on preferences.
// If no separate audio formats exist, prefer muxed video formats (with audio).
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) {
setSelectedVideo(preferred.format_id)
onVideoFormatChange?.(preferred.format_id)
}
}
if (filteredAudios.length > 0 && !selectedAudio) {
const best = filteredAudios[0]
if (finalAudios.length > 0 && !selectedAudio) {
const best = finalAudios[0]
setSelectedAudio(best.format_id)
onAudioFormatChange?.(best.format_id)
}
@@ -215,67 +237,79 @@ export function FormatSelector({
}
if (type === 'video') {
if (videoFormats.length === 0 && audioFormats.length === 0) {
return null
}
return (
<div className="space-y-5">
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{videoFormats.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer"
>
<span>{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectItem value="none" className="cursor-pointer py-2.5">
<span className="text-sm">{t('download.noAudio')}</span>
</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
{audioFormats.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
<span>{t('download.noAudio')}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)
}
// Audio only
if (audioFormats.length === 0) {
return null
}
return (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
@@ -286,17 +320,13 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectContent>
{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 key={format.format_id} value={format.format_id} className="cursor-pointer">
<span>{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -0,0 +1,20 @@
/**
* Renderer process logger utility
* Use electron-log/renderer which automatically forwards logs to main process
*/
import log from 'electron-log/renderer'
// Export electron-log instance
export default log
// Export commonly used logging methods
export const logger = log
// Predefined scoped loggers
export const scopedLoggers = {
renderer: log.scope('renderer'),
error: log.scope('error'),
component: log.scope('component'),
api: log.scope('api')
}

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
"subscriptionDefaults": {
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه."
},
"system": "النظام",
"theme": "المظهر",
@@ -393,7 +392,6 @@
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
"downloadDirectory": "مجلد التحميل",
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
"checkInterval": "فترة الفحص (ساعات)",
"onlyLatest": "تحميل أحدث فيديو فقط",
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
},

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Mehr Formatoptionen anzeigen",
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
"subscriptionDefaults": {
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.",
"intervalDescription": "Wie oft VidBee jeden Abonnement-Feed überprüft (1-24 Stunden)."
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt."
},
"system": "System",
"theme": "Design",
@@ -393,7 +392,6 @@
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
"downloadDirectory": "Download-Verzeichnis",
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
"checkInterval": "Prüfintervall (Stunden)",
"onlyLatest": "Nur das neueste Video herunterladen",
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
},

View File

@@ -50,10 +50,12 @@
"documentation": "Help center",
"documentationDescription": "Guides, FAQs, and common workflows.",
"feedback": "Feedback & issues",
"feedbackDescription": "Share ideas or report issues on GitHub.",
"githubIssues": "GitHub Issues",
"feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
"githubIssues": "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.",
"license": "License",
"licenseDescription": "Review the open-source license terms.",
@@ -204,6 +206,25 @@
"subscription": "Subscription"
}
},
"error": {
"title": "Something went wrong",
"description": "An unexpected error occurred. Please try reloading the application or report this issue if it persists.",
"message": "Error Message",
"unknownError": "Unknown error occurred",
"goHome": "Go Home",
"reload": "Reload App",
"copyReport": "Copy Error Report",
"copied": "Copied!",
"copySuccess": "Error report copied to clipboard",
"copyFailed": "Failed to copy error report",
"showDetails": "Show Details",
"hideDetails": "Hide Details",
"stackTrace": "Stack Trace",
"componentStack": "Component Stack",
"noStackTrace": "No stack trace available",
"fullReport": "Full Error Report",
"helpText": "If this error persists, please copy the error report above and share it with the support team. You can find contact information in the About page."
},
"errors": {
"clickToCopy": "Click to copy details",
"clipboardEmpty": "Clipboard is empty",
@@ -351,7 +372,15 @@
"app": "App Settings",
"audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from",
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
"browserForCookiesProfile": "Profile name or path",
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
"browserForCookiesProfileInvalid": "Profile path is not valid. Choose the profile folder for the selected browser.",
"browserForCookiesProfileInvalidPath": "That folder does not exist. Pick an existing profile folder.",
"browserForCookiesProfileInvalidProfile": "Profile name not found in the default browser location.",
"browserForCookiesProfileInvalidUnsupported": "No default profile location is known for this browser on this platform.",
"browserForCookiesProfileInvalidEmpty": "Enter a profile path for the selected browser.",
"cookiesFile": "Cookies file",
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
"clearCookiesFile": "Clear",
@@ -366,7 +395,10 @@
"chromium": "Chromium",
"edge": "Edge",
"firefox": "Firefox",
"safari": "Safari"
"opera": "Opera",
"safari": "Safari",
"vivaldi": "Vivaldi",
"whale": "Whale"
},
"configFile": "Use configuration file",
"configFileDescription": "Custom configuration file for yt-dlp",
@@ -379,6 +411,7 @@
"fileSelectError": "Failed to select file",
"general": "General",
"language": "Language",
"languageDescription": "Choose your preferred language for the application interface",
"light": "Light",
"hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
@@ -412,8 +445,7 @@
"showMoreFormats": "Show more format options",
"showMoreFormatsDescription": "Display additional format options in the interface",
"subscriptionDefaults": {
"filenameDescription": "Pattern used when a subscription does not override its filename.",
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
"filenameDescription": "Pattern used when a subscription does not override its filename."
},
"system": "System",
"theme": "Theme",
@@ -434,7 +466,6 @@
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
"downloadDirectory": "Download directory",
"filenameTemplate": "Filename template",
"checkInterval": "Check interval (hours)",
"onlyLatest": "Download only the latest video",
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
},

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Mostrar más opciones de formato",
"showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz",
"subscriptionDefaults": {
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo.",
"intervalDescription": "Con qué frecuencia VidBee verifica cada feed de suscripción (1-24 horas)."
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo."
},
"system": "Sistema",
"theme": "Tema",
@@ -393,7 +392,6 @@
"description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.",
"downloadDirectory": "Directorio de descarga",
"filenameTemplate": "Plantilla de nombre de archivo (solo archivo)",
"checkInterval": "Intervalo de verificación (horas)",
"onlyLatest": "Descargar solo el último video",
"onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga."
},

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Afficher plus d'options de format",
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
"subscriptionDefaults": {
"filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier.",
"intervalDescription": "À quelle fréquence VidBee vérifie chaque flux d'abonnement (1 à 24 heures)."
"filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier."
},
"system": "Système",
"theme": "Thème",
@@ -485,7 +484,6 @@
"title": "Ajouter un flux RSS"
},
"defaults": {
"checkInterval": "Intervalle de vérification (heures)",
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
"downloadDirectory": "Répertoire de téléchargement",
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Tampilkan lebih banyak opsi format",
"showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka",
"subscriptionDefaults": {
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya.",
"intervalDescription": "Seberapa sering VidBee memeriksa setiap feed berlangganan (1-24 jam)."
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya."
},
"system": "Sistem",
"theme": "Tema",
@@ -393,7 +392,6 @@
"description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.",
"downloadDirectory": "Direktori unduhan",
"filenameTemplate": "Template nama file (hanya file)",
"checkInterval": "Interval pemeriksaan (jam)",
"onlyLatest": "Unduh hanya video terbaru",
"onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru."
},

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Mostra più opzioni formato",
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
"subscriptionDefaults": {
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file.",
"intervalDescription": "La frequenza con cui VidBee controlla ciascun feed di abbonamento (1-24 ore)."
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file."
},
"system": "Sistema",
"theme": "Tema",
@@ -485,7 +484,6 @@
"title": "Aggiungi RSS"
},
"defaults": {
"checkInterval": "Intervallo di controllo (ore)",
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
"downloadDirectory": "Scarica la directory",
"filenameTemplate": "Modello nome file (solo file)",

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "より多くのフォーマットオプションを表示",
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
"subscriptionDefaults": {
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 24 時間)。"
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。"
},
"system": "システム",
"theme": "テーマ",
@@ -485,7 +484,6 @@
"title": "RSSを追加"
},
"defaults": {
"checkInterval": "チェック間隔(時間)",
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
"downloadDirectory": "ダウンロードディレクトリ",
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "더 많은 형식 옵션 표시",
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
"subscriptionDefaults": {
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.",
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다."
},
"system": "시스템",
"theme": "테마",
@@ -485,7 +484,6 @@
"title": "RSS 추가"
},
"defaults": {
"checkInterval": "확인 간격(시간)",
"description": "구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.",
"downloadDirectory": "디렉토리 다운로드",
"filenameTemplate": "파일 이름 템플릿(파일만)",

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Mostrar mais opções de formato",
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
"subscriptionDefaults": {
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo.",
"intervalDescription": "Com que frequência o VidBee verifica cada feed de assinatura (1 a 24 horas)."
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo."
},
"system": "Sistema",
"theme": "Tema",
@@ -485,7 +484,6 @@
"title": "Adicionar RSS"
},
"defaults": {
"checkInterval": "Intervalo de verificação (horas)",
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
"downloadDirectory": "Baixar diretório",
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",

View File

@@ -371,8 +371,7 @@
"showMoreFormats": "Показать больше вариантов форматов",
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
"subscriptionDefaults": {
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла.",
"intervalDescription": "Как часто VidBee проверяет каждый канал подписки (1-24 часа)."
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла."
},
"system": "Системная",
"theme": "Тема",
@@ -393,7 +392,6 @@
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
"downloadDirectory": "Директория загрузки",
"filenameTemplate": "Шаблон имени файла (только файл)",
"checkInterval": "Интервал проверки (часы)",
"onlyLatest": "Загружать только последнее видео",
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
},

View File

@@ -372,8 +372,7 @@
"showMoreFormats": "顯示更多格式選項",
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
"subscriptionDefaults": {
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。",
"intervalDescription": "VidBee 檢查每個訂閱源的頻率1-24 小時)。"
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。"
},
"system": "系統",
"theme": "主題",
@@ -486,7 +485,6 @@
"title": "添加RSS"
},
"defaults": {
"checkInterval": "檢查間隔(小時)",
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
"downloadDirectory": "下載目錄",
"filenameTemplate": "文件名模板(僅限文件)",

View File

@@ -372,8 +372,7 @@
"showMoreFormats": "显示更多格式选项",
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
"subscriptionDefaults": {
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。",
"intervalDescription": "VidBee 检查每个订阅源的频率1-24 小时)。"
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。"
},
"system": "系统",
"theme": "主题",
@@ -486,7 +485,6 @@
"title": "添加RSS"
},
"defaults": {
"checkInterval": "检查间隔(小时)",
"description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。",
"downloadDirectory": "下载目录",
"filenameTemplate": "文件名模板(仅限文件)",

View File

@@ -6,6 +6,82 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './i18n'
import { logger } from './lib/logger'
// Setup global error handlers
setupGlobalErrorHandlers()
// Get app version asynchronously
let appVersion: string | undefined
if (window?.api && window.electron?.ipcRenderer) {
import('./lib/ipc')
.then(({ ipcServices }) => ipcServices.app.getVersion())
.then((version) => {
appVersion = version
})
.catch((err) => {
logger.warn('Failed to get app version for error reporting:', err)
})
}
function setupGlobalErrorHandlers(): void {
// Handle uncaught JavaScript errors
window.addEventListener('error', (event) => {
logger.error('Uncaught error:', event.error)
if (window?.api) {
try {
window.api.send('error:renderer', {
error: {
name: event.error?.name || 'Error',
message: event.error?.message || event.message || 'Unknown error',
stack: event.error?.stack || event.filename
},
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
version: appVersion,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
})
} catch (err) {
logger.error('Failed to send error to main process:', err)
}
}
})
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
logger.error('Unhandled promise rejection:', event.reason)
if (window?.api) {
try {
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason))
window.api.send('error:renderer', {
error: {
name: error.name || 'UnhandledPromiseRejection',
message: error.message || String(event.reason),
stack: error.stack
},
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
version: appVersion
}
})
} catch (err) {
logger.error('Failed to send error to main process:', err)
}
}
})
}
const rootElement = document.getElementById('root')
if (!rootElement) {

View File

@@ -1,12 +1,6 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { Progress } from '@renderer/components/ui/progress'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
@@ -18,14 +12,16 @@ import {
Github,
Link as LinkIcon,
MessageCircle,
MessageSquare,
RefreshCw,
Twitter
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcEvents, ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings'
import { updateAvailableAtom, updateReadyAtom } from '../store/update'
interface AboutResource {
icon: LucideIcon
@@ -45,6 +41,8 @@ type LatestVersionState =
export function About() {
const { t } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom)
const [updateReady] = useAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
@@ -88,6 +86,10 @@ export function About() {
status: 'available',
version: versionLabel
})
setUpdateAvailable({
available: true,
version: versionLabel
})
// Reset download progress when new update is available
setUpdateDownloadProgress(0)
}
@@ -113,7 +115,7 @@ export function About() {
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
}
}, [t])
}, [setUpdateAvailable, t])
const handleSettingChange = async (
key: keyof typeof settings,
@@ -135,6 +137,10 @@ export function About() {
status: 'available',
version: result.version ?? ''
})
setUpdateAvailable({
available: true,
version: result.version
})
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({
@@ -147,6 +153,10 @@ export function About() {
status: 'uptodate',
version: result.version ?? appVersion
})
setUpdateAvailable({
available: false,
version: undefined
})
}
} catch (error) {
console.error('Failed to check for updates:', error)
@@ -159,6 +169,10 @@ export function About() {
openShareUrl('https://vidbee.org/download/')
}
const handleRestartToUpdate = () => {
void ipcServices.update.quitAndInstall()
}
const handleCheckForUpdates = async () => {
try {
toast.info(t('about.notifications.checkingUpdates'))
@@ -170,6 +184,10 @@ export function About() {
status: 'available',
version: result.version ?? ''
})
setUpdateAvailable({
available: true,
version: result.version
})
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({
@@ -182,6 +200,10 @@ export function About() {
status: 'uptodate',
version: result.version ?? appVersion
})
setUpdateAvailable({
available: false,
version: undefined
})
}
} catch (error) {
console.error('Failed to check for updates:', error)
@@ -202,13 +224,13 @@ export function About() {
}
}, [t])
const openShareUrl = (url: string) => {
const openShareUrl = useCallback((url: string) => {
if (typeof window === 'undefined') {
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
}, [])
const handleShareTwitter = () => {
openShareUrl(shareLinks.twitter)
@@ -243,6 +265,39 @@ export function About() {
: 'text-muted-foreground'
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[]>(
() => [
{
@@ -258,20 +313,6 @@ export function About() {
description: t('about.resources.changelogDescription'),
actionLabel: t('about.actions.view'),
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]
@@ -282,69 +323,87 @@ export function About() {
<div className="container mx-auto max-w-5xl p-6 space-y-6">
<Card>
<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">
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
{latestVersionBadgeText ? (
<Badge variant="outline">{latestVersionBadgeText}</Badge>
) : null}
{latestVersionStatusText ? (
<span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText}
</span>
) : 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" />
<img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
{latestVersionBadgeText ? (
<Badge variant="outline">{latestVersionBadgeText}</Badge>
) : null}
{latestVersionStatusText ? (
<span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText}
</span>
) : null}
</div>
) : null}
</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 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>
{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="space-y-1">
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
@@ -361,7 +420,6 @@ export function About() {
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -408,6 +466,45 @@ export function About() {
<Card>
<CardContent className="p-0">
<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) => {
const Icon = resource.icon
return (

View File

@@ -18,42 +18,75 @@ import {
} from '@renderer/components/ui/select'
import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { logger } from '../lib/logger'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
const clampSubscriptionInterval = (value: string) => {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) {
return 3
const normalizeProfileInput = (value: string) => value.trim().replace(/^['"]|['"]$/g, '')
const parseBrowserCookiesSetting = (value: string | undefined) => {
if (!value || value === 'none') {
return { browser: 'none', profile: '' }
}
return Math.min(24, Math.max(1, parsed))
const separatorIndex = value.indexOf(':')
if (separatorIndex === -1) {
return { browser: value, profile: '' }
}
const browser = value.slice(0, separatorIndex).trim()
const profile = normalizeProfileInput(value.slice(separatorIndex + 1))
return { browser: browser || 'none', profile }
}
const buildBrowserCookiesSetting = (browser: string, profile: string) => {
const trimmedBrowser = browser.trim()
if (!trimmedBrowser || trimmedBrowser === 'none') {
return 'none'
}
const trimmedProfile = normalizeProfileInput(profile)
return trimmedProfile ? `${trimmedBrowser}:${trimmedProfile}` : trimmedBrowser
}
export function Settings() {
const { t } = useTranslation()
const { t, i18n: i18nInstance } = useTranslation()
const { theme, setTheme } = useTheme()
const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const saveSetting = useSetAtom(saveSettingAtom)
const [platform, setPlatform] = useState<string>('')
const [activeTab, setActiveTab] = useState<string>('general')
const [browserProfileValidation, setBrowserProfileValidation] = useState<{
valid: boolean
reason?: string
}>({ valid: false })
const lastAutoDetectBrowser = useRef<string | null>(null)
useEffect(() => {
loadSettings()
try {
loadSettings()
} catch (error) {
logger.error('[Settings] Failed to load settings:', error)
}
}, [loadSettings])
useEffect(() => {
const fetchPlatform = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const platformInfo = await ipcServices.app.getPlatform()
setPlatform(platformInfo)
} catch (error) {
console.error('Failed to get platform info:', error)
logger.error('Failed to get platform info:', error)
}
}
@@ -62,61 +95,61 @@ export function Settings() {
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
}
const handleSettingChange = useCallback(
async (key: keyof typeof settings, value: (typeof settings)[keyof typeof settings]) => {
try {
await saveSetting({ key, value })
} catch (error) {
logger.error('[Settings] Failed to change setting', { key, value, error })
toast.error(t('settings.saveError') || 'Failed to save setting')
}
},
[saveSetting, t]
)
const handleSelectPath = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectDirectory()
if (path) {
await handleSettingChange('downloadPath', path)
}
} catch (error) {
console.error('Failed to select directory:', error)
logger.error('Failed to select directory:', error)
toast.error(t('settings.directorySelectError'))
}
}
const handleSelectConfigFile = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectFile()
if (path) {
await handleSettingChange('configPath', path)
}
} catch (error) {
console.error('Failed to select file:', error)
logger.error('Failed to select file:', error)
toast.error(t('settings.fileSelectError'))
}
}
const handleSelectCookiesFile = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectFile()
if (path) {
await handleSettingChange('cookiesPath', path)
}
} catch (error) {
console.error('Failed to select cookies file:', error)
logger.error('Failed to select cookies file:', error)
toast.error(t('settings.fileSelectError'))
}
}
const handleOpenCookiesFaq = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
await ipcServices.fs.openExternal(
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
)
} catch (error) {
console.error('Failed to open cookies FAQ:', error)
logger.error('Failed to open cookies FAQ:', error)
toast.error(t('settings.openLinkError'))
}
}
@@ -131,6 +164,111 @@ export function Settings() {
await handleSettingChange('theme', value)
}
const languageOptions = languageList
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const parsedBrowserCookies = parseBrowserCookiesSetting(settings.browserForCookies)
const browserForCookiesValue = parsedBrowserCookies.browser
const browserCookiesProfileValue = parsedBrowserCookies.profile
const normalizedBrowserCookiesSetting = buildBrowserCookiesSetting(
browserForCookiesValue,
browserCookiesProfileValue
)
const hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0
const showBrowserProfileCheck = hasBrowserProfileValue && browserProfileValidation.valid
const showBrowserProfileWarning = hasBrowserProfileValue && !browserProfileValidation.valid
const getBrowserProfileWarningMessage = (reason?: string) => {
switch (reason) {
case 'pathNotFound':
return t('settings.browserForCookiesProfileInvalidPath')
case 'profileNotFound':
return t('settings.browserForCookiesProfileInvalidProfile')
case 'browserUnsupported':
return t('settings.browserForCookiesProfileInvalidUnsupported')
case 'empty':
return t('settings.browserForCookiesProfileInvalidEmpty')
default:
return t('settings.browserForCookiesProfileInvalid')
}
}
useEffect(() => {
if (settings.browserForCookies !== normalizedBrowserCookiesSetting) {
void handleSettingChange('browserForCookies', normalizedBrowserCookiesSetting)
}
}, [handleSettingChange, normalizedBrowserCookiesSetting, settings.browserForCookies])
useEffect(() => {
const browserChanged = lastAutoDetectBrowser.current !== browserForCookiesValue
const shouldAutoDetect =
browserForCookiesValue !== 'none' && (browserChanged || !browserCookiesProfileValue)
if (!shouldAutoDetect) {
lastAutoDetectBrowser.current = browserForCookiesValue
return
}
const detectProfilePath = async () => {
try {
const detectedPath =
await ipcServices.browserCookies.getBrowserProfilePath(browserForCookiesValue)
const nextProfileValue = detectedPath || ''
if (nextProfileValue !== browserCookiesProfileValue) {
const nextValue = buildBrowserCookiesSetting(browserForCookiesValue, nextProfileValue)
await handleSettingChange('browserForCookies', nextValue)
}
} catch (error) {
logger.error('[Settings] Failed to detect browser profile path:', error)
} finally {
lastAutoDetectBrowser.current = browserForCookiesValue
}
}
void detectProfilePath()
}, [browserForCookiesValue, browserCookiesProfileValue, handleSettingChange])
useEffect(() => {
if (browserForCookiesValue === 'none' || !hasBrowserProfileValue) {
setBrowserProfileValidation({ valid: false, reason: 'empty' })
return
}
let isActive = true
const validateProfilePath = async () => {
try {
const result = await ipcServices.browserCookies.validateBrowserProfilePath(
browserForCookiesValue,
browserCookiesProfileValue
)
if (isActive) {
setBrowserProfileValidation(result)
}
} catch (error) {
if (isActive) {
setBrowserProfileValidation({ valid: false, reason: 'pathNotFound' })
}
logger.error('[Settings] Failed to validate browser profile path:', error)
}
}
void validateProfilePath()
return () => {
isActive = false
}
}, [browserForCookiesValue, browserCookiesProfileValue, hasBrowserProfileValue])
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18nInstance.changeLanguage(value)
}
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-4xl p-6 space-y-6">
@@ -139,7 +277,12 @@ export function Settings() {
<p className="text-muted-foreground">{t('settings.description')}</p>
</div>
<Tabs defaultValue="general">
<Tabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value)
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
@@ -185,6 +328,53 @@ export function Settings() {
</Select>
</ItemActions>
</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>
@@ -316,41 +506,20 @@ export function Settings() {
</ItemContent>
<ItemActions>
<Switch
checked={settings.showMoreFormats}
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
checked={settings.showMoreFormats ?? false}
onCheckedChange={(value) => {
try {
handleSettingChange('showMoreFormats', value)
} catch (error) {
logger.error('[Settings] Error toggling showMoreFormats:', error)
}
}}
/>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
<ItemDescription>
{t('settings.subscriptionDefaults.intervalDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Input
type="number"
min={1}
max={24}
defaultValue={settings.subscriptionCheckIntervalHours}
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
onBlur={(event) =>
void handleSettingChange(
'subscriptionCheckIntervalHours',
clampSubscriptionInterval(event.target.value)
)
}
className="w-24"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
@@ -359,23 +528,45 @@ export function Settings() {
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.maxConcurrentDownloads.toString()}
onValueChange={(value) =>
handleSettingChange('maxConcurrentDownloads', Number(value))
{(() => {
try {
const maxConcurrent = settings.maxConcurrentDownloads ?? 5
const maxConcurrentStr = maxConcurrent.toString()
return (
<Select
value={maxConcurrentStr}
onValueChange={(value) => {
try {
const numValue = Number(value)
handleSettingChange('maxConcurrentDownloads', numValue)
} catch (error) {
logger.error(
'[Settings] Error changing max concurrent downloads:',
error
)
}
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
)
} catch (error) {
logger.error(
'[Settings] Error rendering max concurrent downloads select:',
error
)
return <div>Error loading max concurrent downloads setting</div>
}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
})()}
</ItemActions>
</Item>
@@ -387,34 +578,28 @@ export function Settings() {
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Input
placeholder={t('settings.proxyPlaceholder')}
value={settings.proxy}
onChange={(e) => handleSettingChange('proxy', e.target.value)}
className="w-64"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.configPath} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
<Button
variant="secondary"
onClick={() => void handleSettingChange('configPath', '')}
disabled={!settings.configPath}
>
{t('settings.clearConfigFile')}
</Button>
</div>
{(() => {
try {
const proxyValue = settings.proxy ?? ''
return (
<Input
placeholder={t('settings.proxyPlaceholder')}
value={proxyValue}
onChange={(e) => {
try {
handleSettingChange('proxy', e.target.value)
} catch (error) {
logger.error('[Settings] Error changing proxy:', error)
}
}}
className="w-64"
/>
)
} catch (error) {
logger.error('[Settings] Error rendering proxy input:', error)
return <div>Error loading proxy setting</div>
}
})()}
</ItemActions>
</Item>
</ItemGroup>
@@ -426,27 +611,126 @@ export function Settings() {
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.browserForCookies}
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('settings.none')}</SelectItem>
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
<SelectItem value="chromium">
{t('settings.browserOptions.chromium')}
</SelectItem>
<SelectItem value="firefox">
{t('settings.browserOptions.firefox')}
</SelectItem>
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
</SelectContent>
</Select>
{(() => {
try {
return (
<Select
value={browserForCookiesValue}
onValueChange={(value) => {
try {
const nextValue = buildBrowserCookiesSetting(value, '')
handleSettingChange('browserForCookies', nextValue)
} catch (error) {
logger.error('[Settings] Error changing browser for cookies:', error)
}
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('settings.none')}</SelectItem>
<SelectItem value="chrome">
{t('settings.browserOptions.chrome')}
</SelectItem>
<SelectItem value="chromium">
{t('settings.browserOptions.chromium')}
</SelectItem>
<SelectItem value="firefox">
{t('settings.browserOptions.firefox')}
</SelectItem>
<SelectItem value="edge">
{t('settings.browserOptions.edge')}
</SelectItem>
<SelectItem value="safari">
{t('settings.browserOptions.safari')}
</SelectItem>
<SelectItem value="brave">
{t('settings.browserOptions.brave')}
</SelectItem>
<SelectItem value="opera">
{t('settings.browserOptions.opera')}
</SelectItem>
<SelectItem value="vivaldi">
{t('settings.browserOptions.vivaldi')}
</SelectItem>
<SelectItem value="whale">
{t('settings.browserOptions.whale')}
</SelectItem>
</SelectContent>
</Select>
)
} catch (error) {
logger.error('[Settings] Error rendering browser for cookies select:', error)
return <div>Error loading browser for cookies setting</div>
}
})()}
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent className="basis-full">
<ItemTitle>{t('settings.browserForCookiesProfile')}</ItemTitle>
<ItemDescription>
{t('settings.browserForCookiesProfileDescription')}
</ItemDescription>
</ItemContent>
<ItemActions className="basis-full">
{(() => {
try {
return (
<div className="relative w-full">
<Input
placeholder={t('settings.browserForCookiesProfilePlaceholder')}
value={browserCookiesProfileValue}
onChange={(event) => {
try {
const newProfileValue = event.target.value
const nextValue = buildBrowserCookiesSetting(
browserForCookiesValue,
newProfileValue
)
handleSettingChange('browserForCookies', nextValue)
} catch (error) {
logger.error(
'[Settings] Error changing browser cookies profile:',
error
)
}
}}
disabled={browserForCookiesValue === 'none'}
className="w-full pr-10"
/>
{showBrowserProfileCheck ? (
<CheckCircle2
className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-emerald-500"
aria-hidden
/>
) : null}
{showBrowserProfileWarning ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="absolute right-3 top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center text-amber-500">
<AlertTriangle className="h-4 w-4" aria-hidden />
</span>
</TooltipTrigger>
<TooltipContent>
{getBrowserProfileWarningMessage(browserProfileValidation.reason)}
</TooltipContent>
</Tooltip>
) : null}
</div>
)
} catch (error) {
logger.error(
'[Settings] Error rendering browser cookies profile input:',
error
)
return <div>Error loading browser cookies profile setting</div>
}
})()}
</ItemActions>
</Item>
@@ -458,17 +742,35 @@ export function Settings() {
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
<Button
variant="secondary"
onClick={() => void handleSettingChange('cookiesPath', '')}
disabled={!settings.cookiesPath}
>
{t('settings.clearCookiesFile')}
</Button>
</div>
{(() => {
try {
const cookiesPathValue = settings.cookiesPath ?? ''
return (
<div className="flex gap-2 w-full max-w-md">
<Input value={cookiesPathValue} readOnly className="flex-1" />
<Button onClick={handleSelectCookiesFile}>
{t('settings.selectPath')}
</Button>
<Button
variant="secondary"
onClick={() => {
try {
void handleSettingChange('cookiesPath', '')
} catch (error) {
logger.error('[Settings] Error clearing cookies path:', error)
}
}}
disabled={!cookiesPathValue}
>
{t('settings.clearCookiesFile')}
</Button>
</div>
)
} catch (error) {
logger.error('[Settings] Error rendering cookies file input:', error)
return <div>Error loading cookies file setting</div>
}
})()}
</ItemActions>
</Item>
@@ -477,12 +779,10 @@ export function Settings() {
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
<ItemDescription>
<ul className="list-disc list-inside space-y-1">
<li>{t('settings.cookiesHelpBrowser')}</li>
<li>{t('settings.cookiesHelpFile')}</li>
</ul>
</ItemDescription>
<ul className="list-disc list-inside space-y-1 text-muted-foreground text-sm leading-normal">
<li>{t('settings.cookiesHelpBrowser')}</li>
<li>{t('settings.cookiesHelpFile')}</li>
</ul>
</ItemContent>
<ItemActions>
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
@@ -490,6 +790,46 @@ export function Settings() {
</Button>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
{(() => {
try {
const configPathValue = settings.configPath ?? ''
return (
<div className="flex gap-2 w-full max-w-md">
<Input value={configPathValue} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>
{t('settings.selectPath')}
</Button>
<Button
variant="secondary"
onClick={() => {
try {
void handleSettingChange('configPath', '')
} catch (error) {
logger.error('[Settings] Error clearing config path:', error)
}
}}
disabled={!configPathValue}
>
{t('settings.clearConfigFile')}
</Button>
</div>
)
} catch (error) {
logger.error('[Settings] Error rendering config file input:', error)
return <div>Error loading config file setting</div>
}
})()}
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
@@ -499,10 +839,26 @@ export function Settings() {
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.enableAnalytics}
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
/>
{(() => {
try {
const analyticsValue = settings.enableAnalytics ?? true
return (
<Switch
checked={analyticsValue}
onCheckedChange={(value) => {
try {
handleSettingChange('enableAnalytics', value)
} catch (error) {
logger.error('[Settings] Error changing enable analytics:', error)
}
}}
/>
)
} catch (error) {
logger.error('[Settings] Error rendering enable analytics switch:', error)
return <div>Error loading enable analytics setting</div>
}
})()}
</ItemActions>
</Item>
</ItemGroup>

View 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
})

View File

@@ -270,7 +270,6 @@ export interface AppSettings {
launchAtLogin: boolean
autoUpdate: boolean
subscriptionOnlyLatestDefault: boolean
subscriptionCheckIntervalHours: number
enableAnalytics: boolean
}
@@ -295,6 +294,5 @@ export const defaultSettings: AppSettings = {
launchAtLogin: false,
autoUpdate: true,
subscriptionOnlyLatestDefault: true,
subscriptionCheckIntervalHours: 3,
enableAnalytics: true
}