goon/mobile/src/screens/SiteScenesScreen.tsx
goon-foss fbecd4cd50 fix(mobile): removeClippedSubviews=false on grids — stop thumbnails vanishing on scroll
Android FlatList defaults removeClippedSubviews=true, which detaches off-viewport
subviews; expo-image frequently fails to re-render them when they scroll back in →
blank thumbnails (bug-report f181d382 2026-06-07, recurring). Disable on all heavy
image grids: scene grids (Scenes/Site/Studio/Tag/Performer) + movie poster grids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:18:48 +02:00

421 lines
14 KiB
TypeScript

// Sceny z konkretnego tube/source — listScenes z origin substring filter.
// Bug-report 2026-05-24 (ea6f05f9): top-level Sites browse → tap site → tutaj.
//
// Sort: release_date DESC żeby user dostał świeże publikacje na górze. Sceny bez
// release_date dryfują na koniec — to znany trade-off (patrz freshporno backfill
// 2026-05-23, 10390 scen miało null date).
//
// Infinite scroll bo niektóre tubey mają 100k+ scen (porntrex, xvideos).
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import React, { useState } from 'react';
import * as Haptics from 'expo-haptics';
import {
ActivityIndicator,
FlatList,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SceneTile } from '../components/SceneTile';
import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { SceneOut, TagOut } from '../types';
export function SiteScenesScreen() {
const client = useClient();
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>();
const route = useRoute<RouteProp<RootStackParamList, 'SiteScenes'>>();
const { origin, name } = route.params;
React.useLayoutEffect(() => {
navigation.setOptions({ title: name });
}, [navigation, name]);
// Tag filter — user-report 2026-05-26 (43f81a46) "Przydałyby się kategorie na
// stronach Sites". Multi-select AND (passed as comma-CSV do `tags` query).
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [filterOpen, setFilterOpen] = useState(false);
const PER_PAGE = 50;
const {
data,
isLoading,
error,
refetch,
isRefetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['site-scenes', origin, selectedTags.sort().join(',')],
queryFn: ({ pageParam = 1 }) =>
client.listScenes({
origin,
tags: selectedTags.length > 0 ? selectedTags : undefined,
sort: 'release_date',
page: pageParam,
per_page: PER_PAGE,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
// Paginuj po has_more (z fetcha per_page+1). `total` bywa bounded ("1000+").
const more =
lastPage.has_more ?? lastPage.page * lastPage.per_page < lastPage.total;
return more ? lastPage.page + 1 : undefined;
},
});
const items = data?.pages.flatMap((p) => p.items) ?? [];
const total = data?.pages[0]?.total ?? 0;
// total bywa bounded ("1000+") dla dużych site'ów — patrz backend _COUNT_CAP.
const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`;
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<Pressable
style={[styles.filterBtn, selectedTags.length > 0 && styles.filterBtnActive]}
onPress={() => setFilterOpen(true)}
>
<Text style={styles.filterBtnText}>
Tags{selectedTags.length > 0 ? ` ${selectedTags.length}` : ''}
</Text>
</Pressable>
{selectedTags.length > 0 ? (
<Pressable style={styles.clearBtn} onPress={() => setSelectedTags([])}>
<Text style={styles.clearBtnText}>Clear</Text>
</Pressable>
) : null}
</View>
<TagPickerModal
visible={filterOpen}
initialSelected={selectedTags}
onApply={(next) => {
setSelectedTags(next);
setFilterOpen(false);
}}
onClose={() => setFilterOpen(false)}
/>
{isLoading && <ActivityIndicator color={theme.fg} style={{ marginTop: 24 }} />}
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
<FlatList
data={items}
keyExtractor={(s) => s.id}
numColumns={2}
removeClippedSubviews={false}
renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
columnWrapperStyle={styles.gridRow}
refreshing={isRefetching}
onRefresh={refetch}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={
data ? (
<Text style={styles.subtitle}>
{totalLabel} {total === 1 ? 'scene' : 'scenes'} · sorted by release date
</Text>
) : null
}
ListFooterComponent={
isFetchingNextPage ? (
<ActivityIndicator color={theme.muted} style={{ marginVertical: 18 }} />
) : !hasNextPage && items.length > 0 ? (
<Text style={styles.muted}>{`${items.length} / ${totalLabel}`}</Text>
) : null
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
</View>
);
}
function TagPickerModal({
visible,
initialSelected,
onApply,
onClose,
}: {
visible: boolean;
initialSelected: string[];
onApply: (selected: string[]) => void;
onClose: () => void;
}) {
const client = useClient();
const [selected, setSelected] = useState<string[]>(initialSelected);
const [q, setQ] = useState('');
React.useEffect(() => {
if (visible) setSelected(initialSelected);
}, [visible, initialSelected]);
const { data, isLoading } = useQuery({
queryKey: ['tags-popular', q],
queryFn: () =>
client.listTags({
q: q.trim() || undefined,
order: 'popular',
per_page: 200,
only_with_content: true,
}),
enabled: visible,
});
const tags: TagOut[] = data?.items ?? [];
const toggle = (slug: string) => {
setSelected((prev) =>
prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug],
);
};
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose} transparent>
<View style={modalStyles.backdrop}>
<View style={modalStyles.sheet}>
<View style={modalStyles.header}>
<Text style={modalStyles.title}>Filter by tags</Text>
<Pressable onPress={onClose}>
<Text style={modalStyles.close}></Text>
</Pressable>
</View>
<TextInput
style={modalStyles.search}
placeholder="Search tags…"
placeholderTextColor={theme.muted}
value={q}
onChangeText={setQ}
autoCapitalize="none"
/>
{isLoading ? (
<ActivityIndicator color={theme.fg} style={{ marginTop: 16 }} />
) : (
<ScrollView style={modalStyles.chipScroll} contentContainerStyle={modalStyles.chipRow}>
{tags.map((t) => {
const on = selected.includes(t.slug);
return (
<Pressable
key={t.id}
onPress={() => toggle(t.slug)}
style={[modalStyles.chip, on && modalStyles.chipOn]}
>
<Text style={[modalStyles.chipText, on && modalStyles.chipTextOn]}>
{t.name}
</Text>
</Pressable>
);
})}
{tags.length === 0 ? (
<Text style={modalStyles.muted}>No results</Text>
) : null}
</ScrollView>
)}
<View style={modalStyles.footer}>
<Pressable
style={modalStyles.footerBtn}
onPress={() => setSelected([])}
disabled={selected.length === 0}
>
<Text
style={[modalStyles.footerBtnText, selected.length === 0 && { opacity: 0.4 }]}
>
Clear
</Text>
</Pressable>
<Pressable
style={[modalStyles.footerBtn, modalStyles.footerBtnPrimary]}
onPress={() => onApply(selected)}
>
<Text style={modalStyles.footerBtnTextPrimary}>
Apply{selected.length > 0 ? ` (${selected.length})` : ''}
</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
function SceneRow({ scene }: { scene: SceneOut }) {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'SiteScenes'>>();
const [isPreviewing, setIsPreviewing] = useState(false);
const performers = scene.performers
.slice(0, 3)
.map((p) => p.canonical_name)
.join(', ');
const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
?.animated_thumbnail_url;
const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
const startPreview = () => {
if (!animatedUrl) return;
setIsPreviewing(true);
Haptics.selectionAsync().catch(() => {});
};
const dim = scene.finished === true;
return (
<Pressable
style={[styles.row, dim && styles.rowDimmed]}
onPress={() => navigation.push('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<Thumb url={displayUrl} style={styles.thumbnail} />
{scene.is_favorite ? (
<View style={styles.favBadge}>
<Text style={styles.favBadgeText}></Text>
</View>
) : null}
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{scene.title}
</Text>
{scene.release_date || scene.studio ? (
<Text style={styles.rowMuted} numberOfLines={1}>
{[scene.release_date, scene.studio?.name].filter(Boolean).join(' · ')}
</Text>
) : null}
{performers ? (
<Text style={styles.rowMuted} numberOfLines={1}>
{performers}
{scene.performers.length > 3 ? ` +${scene.performers.length - 3}` : ''}
</Text>
) : null}
<Text style={styles.rowSources}>
{[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')}
{scene.playback_sources.length > 0
? `${scene.playback_sources.length}`
: ''}
{dim ? ' ✓ watched' : ''}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 },
subtitle: { color: theme.muted, marginBottom: 8, paddingHorizontal: 4 },
row: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 12,
marginBottom: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
rowDimmed: { opacity: 0.45 },
thumbnail: {
width: 100,
height: 56,
borderRadius: 8,
backgroundColor: theme.border,
},
favBadge: {
position: 'absolute',
top: 6,
left: 6,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 8,
},
favBadgeText: { color: theme.accent, fontSize: 12, fontWeight: '700' },
rowContent: { flex: 1 },
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
rowMuted: { color: theme.muted, fontSize: 14, marginTop: 2 },
rowSources: {
color: theme.accent,
fontSize: 12,
marginTop: 8,
textTransform: 'uppercase',
fontWeight: '600',
},
muted: { color: theme.muted, textAlign: 'center', marginTop: 24, fontSize: 14 },
error: { color: theme.bad, padding: 12 },
toolbar: { flexDirection: 'row', gap: 8, marginBottom: 8, alignItems: 'center' },
gridRow: { gap: 10, marginBottom: 14 },
filterBtn: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 10,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
filterBtnActive: { backgroundColor: theme.accentDeep, borderColor: theme.accent },
filterBtnText: { color: theme.fg, fontSize: 13, fontWeight: '600' },
clearBtn: { paddingHorizontal: 10, paddingVertical: 8 },
clearBtnText: { color: theme.muted, fontSize: 13 },
});
const modalStyles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' },
sheet: {
backgroundColor: theme.bg,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '80%',
padding: 16,
},
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
title: { color: theme.fg, fontSize: 17, fontWeight: '700' },
close: { color: theme.muted, fontSize: 20, padding: 4 },
search: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 10,
color: theme.fg,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
marginBottom: 12,
},
chipScroll: { maxHeight: 380 },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, paddingBottom: 8 },
chip: {
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 999,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
chipOn: { backgroundColor: theme.accentDeep, borderColor: theme.accent },
chipText: { color: theme.fg, fontSize: 13 },
chipTextOn: { color: theme.fg, fontWeight: '700' },
muted: { color: theme.muted, fontSize: 13, paddingVertical: 8 },
footer: { flexDirection: 'row', gap: 8, marginTop: 12, justifyContent: 'flex-end' },
footerBtn: { paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10 },
footerBtnText: { color: theme.muted, fontSize: 14, fontWeight: '600' },
footerBtnPrimary: { backgroundColor: theme.accent },
footerBtnTextPrimary: { color: theme.bg, fontSize: 14, fontWeight: '700' },
});