// 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, sceneGridProps } from '../components/SceneTile'; import { Thumb } from '../components/Thumb'; import { useClient } from '../ClientContext'; import { usePreferences } from '../PreferencesContext'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut, TagOut } from '../types'; export function SiteScenesScreen() { const client = useClient(); const { gridColumns } = usePreferences(); const navigation = useNavigation>(); const route = useRoute>(); 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([]); 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 ( 0 && styles.filterBtnActive]} onPress={() => setFilterOpen(true)} > Tags{selectedTags.length > 0 ? ` ${selectedTags.length}` : ''} {selectedTags.length > 0 ? ( setSelectedTags([])}> Clear ) : null} { setSelectedTags(next); setFilterOpen(false); }} onClose={() => setFilterOpen(false)} /> {isLoading && } {error instanceof Error && {error.message}} s.id} removeClippedSubviews={false} renderItem={({ item }) => } refreshing={isRefetching} onRefresh={refetch} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }} onEndReachedThreshold={0.5} ListHeaderComponent={ data ? ( {totalLabel} {total === 1 ? 'scene' : 'scenes'} · sorted by release date ) : null } ListFooterComponent={ isFetchingNextPage ? ( ) : !hasNextPage && items.length > 0 ? ( {`${items.length} / ${totalLabel}`} ) : null } ListEmptyComponent={!isLoading ? no scenes : null} contentContainerStyle={{ paddingBottom: 24 }} /> ); } function TagPickerModal({ visible, initialSelected, onApply, onClose, }: { visible: boolean; initialSelected: string[]; onApply: (selected: string[]) => void; onClose: () => void; }) { const client = useClient(); const [selected, setSelected] = useState(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 ( Filter by tags {isLoading ? ( ) : ( {tags.map((t) => { const on = selected.includes(t.slug); return ( toggle(t.slug)} style={[modalStyles.chip, on && modalStyles.chipOn]} > {t.name} ); })} {tags.length === 0 ? ( No results ) : null} )} setSelected([])} disabled={selected.length === 0} > Clear onApply(selected)} > Apply{selected.length > 0 ? ` (${selected.length})` : ''} ); } function SceneRow({ scene }: { scene: SceneOut }) { const navigation = useNavigation>(); 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 ( navigation.push('SceneDetail', { id: scene.id })} onLongPress={startPreview} onPressOut={() => setIsPreviewing(false)} delayLongPress={180} > {scene.is_favorite ? ( ) : null} {scene.title} {scene.release_date || scene.studio ? ( {[scene.release_date, scene.studio?.name].filter(Boolean).join(' · ')} ) : null} {performers ? ( {performers} {scene.performers.length > 3 ? ` +${scene.performers.length - 3}` : ''} ) : null} {[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')} {scene.playback_sources.length > 0 ? ` ▶ ${scene.playback_sources.length}` : ''} {dim ? ' ✓ watched' : ''} ); } 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' }, });