// Sceny + filmy dla wybranego performera. Tab "Scenes" / "Movies". // Backend filtruje przez `performer_ids=` w /scenes i /movies, oba endpointy // to przyjmują (movies dodane 2026-05-16 bug-report "aktorzy powinni mieć też movies"). // // has_playback ZAWSZE on dla obu, TPDB/StashDB stubs bez linków są niewidoczne. import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import * as Sentry from '@sentry/react-native'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import React from 'react'; import { ActivityIndicator, Alert, FlatList, StyleSheet, Text, View, } from 'react-native'; import { useClient } from '../ClientContext'; import { MoviePosterCard } from '../components/MoviePosterCard'; import { SceneTile, sceneGridProps } from '../components/SceneTile'; import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal'; import { sortNewFirst } from '../lib/newScenes'; import { usePreferences } from '../PreferencesContext'; import { ErrorBoundary } from '../ErrorBoundary'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut } from '../types'; import { HeaderIconButton, IconButton, SegmentTabs, Toolbar } from '../ui'; type Tab = 'scenes' | 'movies'; const MOVIE_COLS = 2; // Ten sam modal i stan filtra co główny feed (prośba 39314780: "similar filters to // when we search for videos by pornstars"), z jedną różnicą: domyślne sortowanie po // dacie premiery, bo tak ten ekran sortował od zawsze i taka jest intuicja "filmografii". const PERFORMER_DEFAULT_FILTER: FilterState = { ...DEFAULT_FILTER, sort: 'release_date' }; export function PerformerScenesScreen() { const client = useClient(); const { gridColumns } = usePreferences(); const queryClient = useQueryClient(); const navigation = useNavigation>(); const route = useRoute>(); const { id, name, seenSince, vertical } = route.params; const [tab, setTab] = React.useState('scenes'); // Sam stan gwiazdki, bez liczników +N: pełne /favorites liczy nowe sceny wszystkich // ulubionych (sekundy przy setkach), a ten ekran potrzebuje tylko listy id. // Klucz pod ['favorites'], więc każde invalidateQueries(['favorites']) odświeża też to. const favoritesQuery = useQuery({ queryKey: ['favorites', 'ids'], queryFn: () => client.listFavorites({ counts: false }), staleTime: 30_000, }); const isFavorite = !!favoritesQuery.data?.items.find((f) => f.performer_id === id); const addMutation = useMutation({ mutationFn: () => client.addFavorite(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites'] }), }); const removeMutation = useMutation({ mutationFn: () => client.removeFavorite(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['favorites'] }), }); const blacklistMutation = useMutation({ mutationFn: () => client.addBlacklist('performer', id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' }); queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] }); queryClient.invalidateQueries({ queryKey: ['performer-movies', id] }); navigation.goBack(); }, }); const refreshMutation = useMutation({ mutationFn: () => client.refreshPerformer(id), onSuccess: (data) => { // Od 2026-08-26 backend startuje wyszukiwanie w tle i odpowiada od razu. // Poprzedni tryb (czekanie na wynik) trwał dłużej, niż telefon utrzymuje // połączenie: przy rozbudowanych performerach każdy tap kończył się "Refresh // failed", mimo że sceny i tak dopisywały się w tle (zgłoszenie 23c64a28). // Listę odświeżamy z opóźnieniem, żeby pierwsze znaleziska były już zapisane. if (data.status === 'started') { Alert.alert( 'Searching…', 'Checking all sites for new scenes in the background. New finds appear within a minute or two; pull down to refresh.', ); setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] }); }, 60_000); return; } queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] }); Alert.alert( 'Refresh complete', `${data.new_scenes} new scenes pulled across tubes.`, ); }, onError: (e: any) => { const msg = e?.message || 'unknown error'; Alert.alert('Refresh failed', msg); }, }); // Chipsy top-tagow performera (bug-report 1a4bf258) zniknely 2026-09-07 razem z // wierszem akcji: tagi sa w filtrze, a naglowek to jeden wiersz ikon jak na Scenes. const onHide = () => { Alert.alert( 'Hide performer', `Hide all scenes featuring ${name}? You can undo this from Settings → Blacklist.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Hide', style: 'destructive', onPress: () => blacklistMutation.mutate() }, ], ); }; // Mark seen przy wejściu (jeśli już ulubiony), zerujemy badge nowych scen React.useEffect(() => { if (isFavorite) { client.markFavoriteSeen(id).then(() => { queryClient.invalidateQueries({ queryKey: ['favorites'] }); }).catch(() => {}); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isFavorite, id]); React.useLayoutEffect(() => { navigation.setOptions({ title: name, headerRight: () => ( (isFavorite ? removeMutation.mutate() : addMutation.mutate())} disabled={addMutation.isPending || removeMutation.isPending} /> ), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]); // Filtr tagu IN-PLACE (bug-report 0264a3ff: wybór tagu ma filtrować sceny tej // aktorki w tym widoku, nie nawigować do osobnego ekranu). null = bez filtra. // Chipsy zostają OBOK pełnego modala: to skrót jednodotykowy na najczęstszy // przypadek, a modal dokłada resztę (jakość, długość, studio, sort, obsada). const [selectedTag, setSelectedTag] = React.useState<{ slug: string; name: string } | null>(null); const [filter, setFilter] = React.useState(PERFORMER_DEFAULT_FILTER); const [seed, setSeed] = React.useState(() => Math.random().toString(36).slice(2) + Date.now().toString(36)); const [filterOpen, setFilterOpen] = React.useState(false); // Licznik liczony WZGLĘDEM domyślnych tego ekranu, nie jak w głównym feedzie, gdzie // domyślne has_playback=true podbija licznik i "Filter 1" świeci na czysto. const filterActiveCount = filter.tagSlugs.length + filter.studioSlugs.length + filter.performerIds.length + (filter.origin.trim() ? 1 : 0) + (filter.minDurationSec > 0 ? 1 : 0) + (filter.minQualityP > 0 ? 1 : 0) + (filter.sort !== PERFORMER_DEFAULT_FILTER.sort ? 1 : 0) + (!filter.hasPlayback ? 1 : 0); const scenesQuery = useQuery({ queryKey: ['performer-scenes', id, selectedTag?.slug ?? null, vertical ?? null, filter, filter.sort === 'random' ? seed : null], queryFn: () => client.listScenes({ // Wybrani w modalu to KONIUNKCJA z bieżącym performerem ("sceny także z..."), // backend łączy performer_ids przez AND. Dubel id jest niegroźny, ale filtrujemy. performer_ids: [id, ...filter.performerIds.filter((p) => p !== id)], vertical: vertical ?? undefined, // 0 = nie nakładaj progu długości. User wszedł JAWNIE w tę pozycję, więc // ukrywanie scen o nieznanej długości (w SQL NULL nie przechodzi `>= 60`) // wyglądało jak pusty albo zatrzymany serwis. Patrz komentarz w api.ts. // Wybór w filtrze ("5+ min" itd.) podnosi próg jawnie. min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : 0, tags: (() => { const merged = new Set(filter.tagSlugs); if (selectedTag) merged.add(selectedTag.slug); return merged.size ? [...merged] : undefined; })(), studio_slugs: filter.studioSlugs.length ? filter.studioSlugs : undefined, origin: filter.origin.trim() || undefined, min_quality_p: filter.minQualityP > 0 ? filter.minQualityP : undefined, include_stubs: filter.includeStubs || undefined, sort: filter.sort, seed: filter.sort === 'random' ? seed : undefined, per_page: 200, // Domyślnie ukrywamy stuby TPDB/StashDB bez tube-linków; wyłączenie w modalu // pokazuje pełną filmografię, z pozycjami, których (jeszcze) nie da się odtworzyć. has_playback: filter.hasPlayback || undefined, }), }); const moviesQuery = useQuery({ queryKey: ['performer-movies', id], queryFn: () => client.listMovies({ performer_ids: [id], sort: 'release_date', per_page: 100, has_playback: true, }), // Lazy: fetcha dopiero przy switch na Movies tab. ~16k pandamovies refów // → query nie powinna lecieć dla każdego performera od razu. enabled: tab === 'movies', }); // Sortowanie: NEW (created_at > seenSince) na górze; reszta po release_date desc // jak zwracane z backendu. Bez `seenSince` (entry spoza Favorites), kolejność nie zmieniana. const sortedScenes = React.useMemo( () => sortNewFirst(scenesQuery.data?.items ?? [], seenSince), [scenesQuery.data?.items, seenSince], ); const movies = moviesQuery.data?.items ?? []; const scenesTotal = scenesQuery.data?.total ?? 0; const scenesSuffix = scenesQuery.data?.total_capped ? '+' : ''; const moviesTotal = moviesQuery.data?.total ?? 0; // Bug-report 2026-05-17 (562cf95c): "Przy przełączaniu na movies app crashed". // Breadcrumb na każdy switch żeby przy crash dostać context co user kliknął // ostatnie ~30s przed crashem (Sentry domyślnie trzyma ostatnie 100 breadcrumbs). const handleTabSwitch = React.useCallback((next: Tab) => { Sentry.addBreadcrumb({ category: 'ui.tab', message: `PerformerScenes tab switch → ${next}`, level: 'info', data: { performerId: id, performerName: name, from: tab, to: next }, }); setTab(next); }, [id, name, tab]); // Jeden wiersz (2026-09-15): zakładki z licznikami po lewej, ikony sceny po prawej. // Wcześniej zakładki, pod nimi wiersz "200+ scenes" z ikonami, czyli trzy linie // razem z nagłówkiem, a licznik stał dwa razy. const tabRow = ( {tab === 'scenes' ? ( <> {refreshMutation.isPending ? ( ) : ( refreshMutation.mutate()} /> )} 0} badge={filterActiveCount} onPress={() => setFilterOpen(true)} /> ) : null} ); return ( {/* Tab row ZAWSZE widoczny, nawet podczas loading. Wcześniej tabRow renderował się tylko jako ListHeaderComponent, pierwsze przejście na Movies tab pokazywało blank screen bez sposobu wrotu do Scenes. */} {tabRow} {tab === 'scenes' ? ( <> {scenesQuery.isLoading && } {scenesQuery.error instanceof Error && ( {scenesQuery.error.message} )} s.id} // Android default removeClippedSubviews=true odpina miniaturki poza // viewportem i expo-image często nie re-renderuje ich po powrocie → // "miniaturki znikają przy scrollu" (bug-report f181d382 2026-06-07). removeClippedSubviews={false} renderItem={({ item }) => ( )} refreshing={scenesQuery.isRefetching} onRefresh={() => { if (filter.sort === 'random') setSeed(Math.random().toString(36).slice(2) + Date.now().toString(36)); else scenesQuery.refetch(); }} ListEmptyComponent={ !scenesQuery.isLoading ? no scenes : null } contentContainerStyle={{ paddingBottom: 24 }} /> ) : ( // Bug-report 2026-05-17 (562cf95c): app crashed przy switch na Movies. // Lokalny boundary łapie React errors w drzewie Movies (np. native crash // w expo-image jeśli poster_url malformed, lub VirtualizedList z dwu-kolumnowym // numColumns). Crash idzie do Sentry z tagiem `boundary:perfscenes-movies`. {moviesQuery.isLoading && } {moviesQuery.error instanceof Error && ( {moviesQuery.error.message} )} m.id} renderItem={({ item }) => { // Defensive: jeśli backend kiedyś wyśle pojedynczy malformed movie, // jeden bad item nie wywala całej listy (FlatList catchuje renderItem // throws, ale wolimy fallback). try { return ( navigation.navigate('MovieDetail', { id: item.id })} /> ); } catch (e) { Sentry.captureException(e, { tags: { component: 'MoviePosterCard' }, contexts: { movie: { id: item?.id, title: item?.title } }, }); return null; } }} refreshing={moviesQuery.isRefetching} onRefresh={moviesQuery.refetch} ListEmptyComponent={ !moviesQuery.isLoading ? ( no movies for this performer ) : null } // columnWrapperStyle musi być w obecności numColumns>1, pusta `data=[]` // z columnWrapperStyle ALE bez gap krzyczy w niektórych RN; trzymamy gap. columnWrapperStyle={movies.length > 0 ? { gap: 8 } : undefined} contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: 6 }} /> )} { setFilter(next); setFilterOpen(false); }} onClose={() => setFilterOpen(false)} /> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 12, paddingTop: 8 }, gridRow: { gap: 10, marginBottom: 14 }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, paddingHorizontal: 4, }, subtitle: { color: theme.muted }, muted: { color: theme.muted, textAlign: 'center', marginTop: 24 }, error: { color: theme.bad, padding: 12 }, refreshBtnLoading: { opacity: 0.6 }, actionRow: { flexDirection: 'row', gap: 6, marginHorizontal: 4, marginBottom: 8, }, actionBtn: { flex: 1, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 8, paddingVertical: 6, alignItems: 'center', }, actionBtnActive: { borderColor: theme.accent }, actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 }, actionBtnTextPrimary: { color: theme.accentText, fontWeight: '700', fontSize: 12 }, tagRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 4, paddingBottom: 10, alignItems: 'center', }, tagChip: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 14, paddingVertical: 4, paddingHorizontal: 10, maxWidth: 200, }, tagChipActive: { backgroundColor: theme.accent, borderColor: theme.accentText }, tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' }, tagChipTextActive: { color: theme.fg, fontWeight: '700' }, });