diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0be8f38..c90b0aa 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,7 +8,7 @@ import { SettingsView } from './components/SettingsView' import { Onboarding } from './components/Onboarding' import { getTheme, pageBackground } from './theme' import { useSettings } from './store/settings' -import { useSystemTheme } from './store/systemTheme' +import { useResolvedDark } from './store/systemTheme' const useStyles = makeStyles({ provider: { @@ -32,8 +32,7 @@ function App(): React.JSX.Element { const theme = useSettings((s) => s.theme) const accentColor = useSettings((s) => s.accentColor) const updateSettings = useSettings((s) => s.update) - const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors) - const isDark = theme === 'system' ? systemPrefersDark : theme === 'dark' + const isDark = useResolvedDark() const [tab, setTab] = useState('downloads') // AeroFetch's own version, shown in the sidebar. Loaded once over IPC. diff --git a/src/renderer/src/components/HistoryView.tsx b/src/renderer/src/components/HistoryView.tsx index a6536e3..2b0866f 100644 --- a/src/renderer/src/components/HistoryView.tsx +++ b/src/renderer/src/components/HistoryView.tsx @@ -14,8 +14,6 @@ import { OpenRegular, FolderRegular, DeleteRegular, - VideoClipRegular, - MusicNote2Regular, HistoryRegular, SearchRegular, ArrowClockwiseRegular, @@ -24,9 +22,11 @@ import { } from '@fluentui/react-icons' import type { HistoryEntry, MediaKind } from '@shared/ipc' import { useHistory } from '../store/history' -import { useSettings } from '../store/settings' +import { useResolvedDark } from '../store/systemTheme' import { useDownloads } from '../store/downloads' import { thumbColors } from '../theme' +import { thumbUrl } from '../thumb' +import { MediaThumb } from './MediaThumb' import { Hint } from './Hint' import { Select } from './Select' @@ -147,7 +147,7 @@ function formatWhen(ts: number): string { export function HistoryView(): React.JSX.Element { const styles = useStyles() - const isDark = useSettings((s) => s.theme === 'dark') + const isDark = useResolvedDark() const tc = thumbColors[isDark ? 'dark' : 'light'] const entries = useHistory((s) => s.entries) const openFile = useHistory((s) => s.openFile) @@ -282,7 +282,6 @@ export function HistoryView(): React.JSX.Element { ) : (
{filtered.map((h: HistoryEntry) => { - const t = h.kind === 'audio' ? tc.audio : tc.video return (
{selectMode && ( @@ -292,13 +291,12 @@ export function HistoryView(): React.JSX.Element { aria-label={`Select ${h.title}`} /> )} -
- {h.kind === 'audio' ? ( - - ) : ( - - )} -
+
{h.title} diff --git a/src/renderer/src/components/LibraryView.tsx b/src/renderer/src/components/LibraryView.tsx index 0e7a2ce..0fffe6a 100644 --- a/src/renderer/src/components/LibraryView.tsx +++ b/src/renderer/src/components/LibraryView.tsx @@ -31,6 +31,8 @@ import type { MediaItem, Source } from '@shared/ipc' import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources' import { useSettings } from '../store/settings' import { useDownloads, type DownloadStatus } from '../store/downloads' +import { thumbUrl } from '../thumb' +import { MediaThumb } from './MediaThumb' // True in the standalone browser preview (no Electron preload). const PREVIEW = typeof window === 'undefined' || !window.electron @@ -148,6 +150,11 @@ const useStyles = makeStyles({ gap: '10px', padding: '5px 4px 5px 18px' }, + rowThumb: { + width: '60px', + height: '34px', + ...shorthands.borderRadius(tokens.borderRadiusSmall) + }, rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 }, rowTitle: { whiteSpace: 'nowrap', @@ -494,6 +501,12 @@ export function LibraryView(): React.JSX.Element { onChange={(_, d) => toggle(it.id, !!d.checked)} aria-label={`Select ${it.title}`} /> +
{it.playlistIndex}. {it.title} diff --git a/src/renderer/src/components/MediaThumb.tsx b/src/renderer/src/components/MediaThumb.tsx new file mode 100644 index 0000000..3b1b044 --- /dev/null +++ b/src/renderer/src/components/MediaThumb.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { makeStyles, mergeClasses } from '@fluentui/react-components' +import { VideoClipRegular, MusicNote2Regular } from '@fluentui/react-icons' +import type { MediaKind } from '@shared/ipc' +import { useResolvedDark } from '../store/systemTheme' +import { thumbColors } from '../theme' + +const useStyles = makeStyles({ + box: { + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden' + }, + img: { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block' + } +}) + +/** + * A media preview thumbnail: shows the image at `src` when one is available and + * loads, otherwise a kind icon on a quiet neutral tint (the same palette the + * placeholders used before). The load failure is tracked per-src, so a thumbnail + * that arrives later (e.g. resolved after a probe) is retried rather than left on + * the fallback. The caller sizes the box via `className` (width/height/radius). + */ +export function MediaThumb({ + src, + kind, + iconSize = 24, + className +}: { + src?: string + kind: MediaKind + iconSize?: number + className?: string +}): React.JSX.Element { + const styles = useStyles() + const isDark = useResolvedDark() + const colors = thumbColors[isDark ? 'dark' : 'light'][kind === 'audio' ? 'audio' : 'video'] + const [failedSrc, setFailedSrc] = useState(null) + const showImg = !!src && failedSrc !== src + + // The neutral tint always backs the box, so there's a placeholder behind the + // image while it loads (and the kind icon stays legible when there's no image). + return ( +
+ {showImg ? ( + setFailedSrc(src ?? null)} + /> + ) : kind === 'audio' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/QueueItem.tsx b/src/renderer/src/components/QueueItem.tsx index 7137e33..394d5bc 100644 --- a/src/renderer/src/components/QueueItem.tsx +++ b/src/renderer/src/components/QueueItem.tsx @@ -15,15 +15,13 @@ import { OpenRegular, FolderRegular, ArrowClockwiseRegular, - VideoClipRegular, - MusicNote2Regular, CheckmarkCircleFilled, ErrorCircleFilled, EyeOffRegular } from '@fluentui/react-icons' import { useDownloads, type DownloadItem, type DownloadStatus } from '../store/downloads' -import { useSettings } from '../store/settings' -import { thumbColors } from '../theme' +import { thumbUrl } from '../thumb' +import { MediaThumb } from './MediaThumb' import { Hint } from './Hint' const useStyles = makeStyles({ @@ -103,8 +101,6 @@ function pct(progress: number): string { export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element { const styles = useStyles() - const isDark = useSettings((s) => s.theme === 'dark') - const thumb = thumbColors[isDark ? 'dark' : 'light'][item.kind === 'audio' ? 'audio' : 'video'] const cancel = useDownloads((s) => s.cancel) const remove = useDownloads((s) => s.remove) const retry = useDownloads((s) => s.retry) @@ -124,13 +120,12 @@ export function QueueItem({ item }: { item: DownloadItem }): React.JSX.Element { return (
-
- {item.kind === 'audio' ? ( - - ) : ( - - )} -
+
diff --git a/src/renderer/src/store/systemTheme.ts b/src/renderer/src/store/systemTheme.ts index 89f94de..12095c9 100644 --- a/src/renderer/src/store/systemTheme.ts +++ b/src/renderer/src/store/systemTheme.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import type { SystemThemeInfo } from '@shared/ipc' +import { useSettings } from './settings' // True in the standalone browser preview (no Electron preload). const PREVIEW = typeof window === 'undefined' || !window.electron @@ -24,3 +25,16 @@ if (!PREVIEW) { useSystemTheme.setState({ shouldUseDarkColors: e.matches }) ) } + +/** + * Whether the app is currently rendering in dark mode. Resolves the 'system' + * preference against the live OS signal, so a component never has to special-case + * it (the bug where a raw `theme === 'dark'` check left thumbnails light under + * Auto + a dark OS). The single source of truth for light/dark — used by App for + * the FluentProvider and by anything that needs theme-aware colors (thumbColors). + */ +export function useResolvedDark(): boolean { + const theme = useSettings((s) => s.theme) + const systemPrefersDark = useSystemTheme((s) => s.shouldUseDarkColors) + return theme === 'system' ? systemPrefersDark : theme === 'dark' +} diff --git a/src/renderer/src/thumb.ts b/src/renderer/src/thumb.ts new file mode 100644 index 0000000..d44e0fc --- /dev/null +++ b/src/renderer/src/thumb.ts @@ -0,0 +1,47 @@ +/** + * Thumbnail helpers. A media "preview" thumbnail is either the explicit image URL + * yt-dlp handed us at probe time, or — for a YouTube video — one derived from the + * video id with no extra network probe. Anything non-YouTube without a probed + * thumbnail returns undefined, and the UI falls back to a kind icon. + */ + +/** + * Pull the video id out of any YouTube watch / shorts / embed / live / youtu.be + * URL. Returns null for non-YouTube URLs or anything unparseable, so callers can + * cheaply ask "is there a thumbnail derivable from this link?". + */ +export function youtubeId(url: string | undefined): string | null { + if (!url) return null + let u: URL + try { + u = new URL(url) + } catch { + return null + } + const host = u.hostname.replace(/^www\./i, '').toLowerCase() + if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null + if (host === 'youtube.com' || host.endsWith('.youtube.com')) { + const v = u.searchParams.get('v') + if (v) return v + const m = u.pathname.match(/^\/(?:embed|shorts|v|live)\/([^/?#]+)/i) + if (m) return m[1] + } + return null +} + +/** + * Resolve a preview thumbnail URL for a media item. Prefers an explicit thumbnail + * (from a probe), then a known videoId, then a YouTube URL it can derive an id + * from. Returns undefined when nothing usable is available. + */ +export function thumbUrl(opts: { + thumbnail?: string + videoId?: string + url?: string +}): string | undefined { + if (opts.thumbnail) return opts.thumbnail + const id = opts.videoId || youtubeId(opts.url) + // mqdefault is 320×180 (16:9, no letterbox bars) and exists for every public + // video — a better fit for our 16:9 thumb boxes than the 4:3 hqdefault. + return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/mqdefault.jpg` : undefined +}