import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import React, { useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { Image } from 'expo-image'; import * as Haptics from 'expo-haptics'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { SceneGridSkeleton } from '../components/SceneGridSkeleton'; import { Thumb } from '../components/Thumb'; import { useClient } from '../ClientContext'; import { usePreferences } from '../PreferencesContext'; import { theme } from '../theme'; import type { RootStackParamList } from '../navigation'; import type { SceneOut } from '../types'; import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal'; export function ScenesScreen() { const client = useClient(); const { gridColumns } = usePreferences(); const navigation = useNavigation>(); const [q, setQ] = useState(''); const [debouncedQ, setDebouncedQ] = useState(''); const [filter, setFilter] = useState(DEFAULT_FILTER); const [filterOpen, setFilterOpen] = useState(false); React.useEffect(() => { const t = setTimeout(() => setDebouncedQ(q), 350); return () => clearTimeout(t); }, [q]); const PER_PAGE = 50; const { data, isLoading, error, refetch, isRefetching, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ['scenes', debouncedQ, filter], queryFn: ({ pageParam = 1 }) => client.listScenes({ q: debouncedQ || undefined, tags: filter.tagSlugs.length ? filter.tagSlugs : undefined, studio_slugs: filter.studioSlugs.length ? filter.studioSlugs : undefined, performer_ids: filter.performerIds.length ? filter.performerIds : undefined, has_playback: filter.hasPlayback || undefined, sort: filter.sort, include_stubs: filter.includeStubs || undefined, origin: filter.origin.trim() || undefined, // 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum. min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : undefined, page: pageParam, per_page: PER_PAGE, }), initialPageParam: 1, getNextPageParam: (lastPage) => { // Paginuj po has_more (źródło prawdy z fetcha per_page+1). `total` jest dla // list filtrowanych bounded ("1000+"), więc NIE nadaje się do liczenia stron. // Fallback na loaded p.items) ?? []; const total = data?.pages[0]?.total ?? 0; // total bywa bounded ("1000+") dla list filtrowanych (q/tag) — patrz backend _COUNT_CAP. const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`; const activeCount = filter.tagSlugs.length + filter.studioSlugs.length + filter.performerIds.length + (filter.hasPlayback ? 1 : 0) + (filter.origin.trim() ? 1 : 0); // Saved searches — zapisane słowa kluczowe (user-report mobilism). const queryClient = useQueryClient(); const savedQuery = useQuery({ queryKey: ['saved-searches'], queryFn: () => client.listSavedSearches(), }); const saved = savedQuery.data ?? []; const addSaved = useMutation({ mutationFn: (query: string) => client.addSavedSearch(query), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }), }); const removeSaved = useMutation({ mutationFn: (id: string) => client.removeSavedSearch(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }), }); const applySaved = (query: string) => { setQ(query); setDebouncedQ(query); // natychmiast, bez czekania na debounce }; const alreadySaved = saved.some((s) => s.query === q.trim()); return ( {q.trim().length > 0 ? ( { const v = q.trim(); if (v && !alreadySaved) addSaved.mutate(v); }} disabled={alreadySaved} style={[styles.toolbarButton, alreadySaved && styles.toolbarButtonActive]} > {alreadySaved ? '★ Saved' : '☆ Save'} ) : null} setFilterOpen(true)} style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]} > 0 && { color: theme.accent }]}> Filter{activeCount > 0 ? ` ${activeCount}` : ''} {saved.length > 0 ? ( {saved.map((s) => { const active = debouncedQ === s.query; return ( applySaved(s.query)} onLongPress={() => Alert.alert('Remove saved search', `Remove "${s.query}"?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removeSaved.mutate(s.id), }, ]) } > {s.query} ); })} ) : null} navigation.navigate('Performers')} > Performers navigation.navigate('Tags')} > Tags {error instanceof Error && {error.message}} {isLoading ? ( ) : ( s.id} // removeClippedSubviews + okno renderowania (windowSize itd.) idą z sceneGridProps // — patrz SceneTile. Perf bug-report 5b7ca1e1 (jank/obciążenie przy długim scrollu). renderItem={({ item }) => } ListHeaderComponent={!debouncedQ && activeCount === 0 ? : null} refreshing={isRefetching} onRefresh={refetch} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }} onEndReachedThreshold={0.5} ListFooterComponent={ isFetchingNextPage ? ( ) : !hasNextPage && items.length > 0 ? ( {`${items.length} / ${totalLabel}`} ) : null } ListEmptyComponent={!isLoading ? no scenes : null} contentContainerStyle={{ paddingBottom: 24 }} /> )} { setFilter(next); setFilterOpen(false); }} onClose={() => setFilterOpen(false)} /> ); } function ContinueWatchingRail() { const client = useClient(); const navigation = useNavigation>(); const { data } = useQuery({ queryKey: ['watch-recent'], queryFn: () => client.listRecentWatch(10), staleTime: 30_000, }); const items = data?.items ?? []; if (items.length === 0) return null; return ( Continue watching {items.map((entry) => { const scene = entry.scene; const thumb = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url; const dur = entry.duration_sec || scene.duration_sec || 0; const pct = dur > 0 ? Math.min(100, Math.round((entry.position_sec / dur) * 100)) : 0; return ( navigation.navigate('SceneDetail', { id: scene.id })} > {pct > 0 ? ( ) : null} {scene.title} ); })} ); } function FavoritesButton() { const client = useClient(); const navigation = useNavigation>(); const { data } = useQuery({ queryKey: ['favorites'], queryFn: () => client.listFavorites(), refetchOnWindowFocus: true, staleTime: 30_000, }); const newTotal = data?.new_total ?? 0; const hasNew = newTotal > 0; return ( navigation.navigate('Favorites')} > ★ {hasNew ? newTotal : ''} ); } 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(() => {}); }; // Watched indicator: finished=True dim'uje cały kafelek; pośredni progress (>0, // navigation.navigate('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: 16, paddingTop: 16 }, toolbar: { flexDirection: 'row', gap: 12, marginBottom: 12 }, search: { flex: 1, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1.5, borderRadius: 12, color: theme.fg, padding: 12, fontSize: 16, }, toolbarButton: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1.5, borderRadius: 12, paddingHorizontal: 16, justifyContent: 'center', }, toolbarButtonActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` }, toolbarButtonText: { color: theme.fg, fontWeight: '700' }, savedRow: { flexGrow: 0, marginBottom: 12 }, savedRowContent: { gap: 8, paddingRight: 12 }, savedChip: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 14, paddingHorizontal: 12, paddingVertical: 6, maxWidth: 200, }, savedChipActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` }, savedChipText: { color: theme.fg, fontSize: 13 }, savedChipTextActive: { color: theme.accent, fontWeight: '600' }, subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 }, subButton: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1.5, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 8, }, subButtonActive: { backgroundColor: theme.accent, borderColor: theme.accent, shadowColor: theme.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.5, shadowRadius: 6, elevation: 3, }, subButtonText: { color: theme.accentSecondary, fontSize: 13, fontWeight: '700' }, subButtonTextActive: { color: theme.fg }, row: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 12, padding: 12, marginBottom: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 3, flexDirection: 'row', alignItems: 'center', gap: 12, }, rowDimmed: { opacity: 0.45 }, 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' }, thumbnail: { width: 100, height: 56, borderRadius: 8, backgroundColor: theme.border, }, 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: 32, fontSize: 16 }, error: { color: theme.bad, padding: 16 }, chip: { borderColor: theme.border, borderWidth: 1.5, borderRadius: 18, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: theme.card, }, chipActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}26`, shadowColor: theme.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, shadowRadius: 6, elevation: 2, }, chipText: { color: theme.muted, fontSize: 13, fontWeight: '700' }, chipTextActive: { color: theme.accent }, // Continue watching rail rail: { marginBottom: 16 }, railLabel: { color: theme.muted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 1.2, fontWeight: '700', marginBottom: 8, paddingLeft: 2, }, railContent: { gap: 10, paddingRight: 8 }, railItem: { width: 160 }, railThumb: { width: 160, height: 90, borderRadius: 8, backgroundColor: theme.bgElevated, }, railThumbPlaceholder: { borderColor: theme.border, borderWidth: 1, }, railProgressBg: { height: 3, backgroundColor: theme.bgElevated, borderRadius: 2, marginTop: 4, overflow: 'hidden', }, railProgressFg: { height: 3, backgroundColor: theme.accent }, railTitle: { color: theme.fg, fontSize: 12, fontWeight: '600', marginTop: 6, lineHeight: 15, }, // 2-col grid wrapper — SceneTile sam ma styling tile/thumb/etc, tu tylko // odstęp między tilami w wierszu i pod wierszem. gridRow: { gap: 10, marginBottom: 14 }, });