fix(mobile): show Refresh thumbnail when the hero image actually fails to load

The button keyed on thumbnail_url presence, but a URL can be present yet broken (hqfap
404 → blank hero, no button — report ef0c6a5a). Tie it to the hero Image load state
(onLoad ok / onError broken / no url none) and show Refresh only when the image is
broken or missing. Reconciles 26c114ed (hidden for good previews) with ef0c6a5a.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-14 14:18:44 +02:00
parent 81d617efc2
commit b66dd99eba

View file

@ -134,6 +134,14 @@ export function SceneDetailScreen() {
onError: (e) => Alert.alert('Thumbnail', e instanceof Error ? e.message : String(e)), onError: (e) => Alert.alert('Thumbnail', e instanceof Error ? e.message : String(e)),
}); });
// Stan ładowania hero-miniatury: 'ok' (załadowała się — domyślnie, brak migotania
// przycisku), 'none' (brak URL), 'broken' (onError). Steruje widocznością przycisku
// Refresh. Reset przy zmianie sceny.
const [thumbState, setThumbState] = React.useState<'none' | 'ok' | 'broken'>('ok');
React.useEffect(() => {
setThumbState('ok');
}, [id]);
// Auto-enrich tags: search-only scrapery nie pobierają tagów z detail page. // Auto-enrich tags: search-only scrapery nie pobierają tagów z detail page.
// Pornhat ma `js-ajax-tag` data-setup JSON; xhamster/xvideos/youporn/inne mają // Pornhat ma `js-ajax-tag` data-setup JSON; xhamster/xvideos/youporn/inne mają
// dedicated tag_extract patterny. SceneDetail wywołuje 1 fetch → upsert do DB. // dedicated tag_extract patterny. SceneDetail wywołuje 1 fetch → upsert do DB.
@ -235,30 +243,25 @@ export function SceneDetailScreen() {
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
<Hero data={data} /> <Hero data={data} onThumbState={setThumbState} />
<View style={styles.body}> <View style={styles.body}>
{/* Refresh tylko gdy BRAK dobrej miniatury (brak w ogóle lub rotting sxyprn/ {/* Refresh pokazujemy gdy wyświetlana miniatura faktycznie się NIE ładuje
trafficdeposit) feedback 26c114ed: na scenach z dobrą miniaturą przycisk (brak URL lub obraz padł onError), nie tylko po obecności URL. Łączy
był zbędnym szumem. Dla zepsutych/stałych nadal się pojawia (cel d3376a71). */} feedback 26c114ed (nie na każdej scenie z dobrą miniaturą) i ef0c6a5a
{(() => { (thumbnail_url bywa obecny, ale obraz 404 user widzi puste pole). */}
const hasTube = data.playback_sources.some((s) => s.origin?.startsWith('tube:')); {data.playback_sources.some((s) => s.origin?.startsWith('tube:')) &&
const hasGoodThumb = data.playback_sources.some( thumbState !== 'ok' ? (
(s) => s.thumbnail_url && !/trafficdeposit|sxyprn|\/proxy\/sxyprn-thumb/i.test(s.thumbnail_url), <Pressable
); style={styles.refreshThumbBtn}
if (!hasTube || hasGoodThumb) return null; onPress={() => refreshThumbMutation.mutate()}
return ( disabled={refreshThumbMutation.isPending}
<Pressable >
style={styles.refreshThumbBtn} <Text style={styles.refreshThumbText}>
onPress={() => refreshThumbMutation.mutate()} {refreshThumbMutation.isPending ? 'Refreshing thumbnail…' : '↻ Refresh thumbnail'}
disabled={refreshThumbMutation.isPending} </Text>
> </Pressable>
<Text style={styles.refreshThumbText}> ) : null}
{refreshThumbMutation.isPending ? 'Refreshing thumbnail…' : '↻ Refresh thumbnail'}
</Text>
</Pressable>
);
})()}
{(data.code || data.director) && ( {(data.code || data.director) && (
<View style={styles.metaRow}> <View style={styles.metaRow}>
@ -379,7 +382,13 @@ export function SceneDetailScreen() {
); );
} }
function Hero({ data }: { data: SceneOut }) { function Hero({
data,
onThumbState,
}: {
data: SceneOut;
onThumbState?: (s: 'none' | 'ok' | 'broken') => void;
}) {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const heroHeight = Math.round(width * (9 / 16)); const heroHeight = Math.round(width * (9 / 16));
@ -388,6 +397,10 @@ function Hero({ data }: { data: SceneOut }) {
const staticUrl = data.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url; const staticUrl = data.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const url = animatedUrl ?? staticUrl; const url = animatedUrl ?? staticUrl;
React.useEffect(() => {
if (!url) onThumbState?.('none');
}, [url, onThumbState]);
const minutes = data.duration_sec ? Math.floor(data.duration_sec / 60) : null; const minutes = data.duration_sec ? Math.floor(data.duration_sec / 60) : null;
const meta = [ const meta = [
data.release_date ?? null, data.release_date ?? null,
@ -415,6 +428,8 @@ function Hero({ data }: { data: SceneOut }) {
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
onLoad={() => onThumbState?.('ok')}
onError={() => onThumbState?.('broken')}
/> />
<LinearGradient <LinearGradient
colors={['transparent', 'rgba(8,9,15,0.55)', theme.bg]} colors={['transparent', 'rgba(8,9,15,0.55)', theme.bg]}