feat: show real media thumbnails with theme-aware fallback
Add MediaThumb component and thumb.ts helper that resolve a preview image from a probed thumbnail, a known videoId, or a derivable YouTube id (mqdefault.jpg), falling back to a kind icon on a neutral tint when no image is available or it fails to load. Wire it into the queue, history, and library rows. Also fix theme resolution: introduce useResolvedDark() as the single source of truth for light/dark so 'system' is resolved against the live OS signal, fixing thumbnails/colors staying light under Auto + dark OS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<TabValue>('downloads')
|
||||
|
||||
// AeroFetch's own version, shown in the sidebar. Loaded once over IPC.
|
||||
|
||||
@@ -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 {
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{filtered.map((h: HistoryEntry) => {
|
||||
const t = h.kind === 'audio' ? tc.audio : tc.video
|
||||
return (
|
||||
<div key={h.id} className={styles.row}>
|
||||
{selectMode && (
|
||||
@@ -292,13 +291,12 @@ export function HistoryView(): React.JSX.Element {
|
||||
aria-label={`Select ${h.title}`}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.thumb} style={{ backgroundColor: t.bg, color: t.fg }}>
|
||||
{h.kind === 'audio' ? (
|
||||
<MusicNote2Regular fontSize={22} />
|
||||
) : (
|
||||
<VideoClipRegular fontSize={22} />
|
||||
)}
|
||||
</div>
|
||||
<MediaThumb
|
||||
className={styles.thumb}
|
||||
src={thumbUrl({ thumbnail: h.thumbnail, url: h.url })}
|
||||
kind={h.kind}
|
||||
iconSize={22}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<Text className={styles.title}>{h.title}</Text>
|
||||
<Caption1 className={styles.meta}>
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
<MediaThumb
|
||||
className={styles.rowThumb}
|
||||
src={thumbUrl({ url: it.url, videoId: it.videoId })}
|
||||
kind="video"
|
||||
iconSize={16}
|
||||
/>
|
||||
<div className={styles.rowMain}>
|
||||
<span className={styles.rowTitle}>
|
||||
{it.playlistIndex}. {it.title}
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<div
|
||||
className={mergeClasses(styles.box, className)}
|
||||
style={{ backgroundColor: colors.bg, color: colors.fg }}
|
||||
>
|
||||
{showImg ? (
|
||||
<img
|
||||
className={styles.img}
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailedSrc(src ?? null)}
|
||||
/>
|
||||
) : kind === 'audio' ? (
|
||||
<MusicNote2Regular fontSize={iconSize} />
|
||||
) : (
|
||||
<VideoClipRegular fontSize={iconSize} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.thumb} style={{ backgroundColor: thumb.bg, color: thumb.fg }}>
|
||||
{item.kind === 'audio' ? (
|
||||
<MusicNote2Regular fontSize={28} />
|
||||
) : (
|
||||
<VideoClipRegular fontSize={28} />
|
||||
)}
|
||||
</div>
|
||||
<MediaThumb
|
||||
className={styles.thumb}
|
||||
src={thumbUrl({ thumbnail: item.thumbnail, url: item.url })}
|
||||
kind={item.kind}
|
||||
iconSize={28}
|
||||
/>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.titleRow}>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user