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>
421 lines
14 KiB
TypeScript
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' },
|
|
});
|