// 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, Pressable, StyleSheet, Text, View, } from 'react-native'; import { useClient } from '../ClientContext'; import { FavoriteSceneRow } from '../components/FavoriteSceneRow'; import { MoviePosterCard } from '../components/MoviePosterCard'; import { SceneTile } from '../components/SceneTile'; import { ErrorBoundary } from '../ErrorBoundary'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { SceneOut } from '../types'; type Tab = 'scenes' | 'movies'; const MOVIE_COLS = 2; export function PerformerScenesScreen() { const client = useClient(); const queryClient = useQueryClient(); const navigation = useNavigation>(); const route = useRoute>(); const { id, name, seenSince } = route.params; const [tab, setTab] = React.useState('scenes'); const favoritesQuery = useQuery({ queryKey: ['favorites'], queryFn: () => client.listFavorites(), 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'] }); queryClient.invalidateQueries({ queryKey: ['performer-scenes', id] }); queryClient.invalidateQueries({ queryKey: ['performer-movies', id] }); navigation.goBack(); }, }); const refreshMutation = useMutation({ mutationFn: () => client.refreshPerformer(id), onSuccess: (data) => { 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); }, }); // Top tagi/kategorie performera (chipsy w headerze, bug-report 1a4bf258 — zastąpiły // dev-only przycisk Re-scrape). Backend agreguje scene_tags po scenach z żywym // playbackiem. Tap → TagScenes. Rescrape (bulk enrich miniaturek/tagów) został // przeniesiony do flow per-scene (SceneDetail) — na liście performera był devowy szum. const tagsQuery = useQuery({ queryKey: ['performer-tags', id], queryFn: () => client.performerTags(id, 18), staleTime: 5 * 60_000, }); 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())} hitSlop={12} disabled={addMutation.isPending || removeMutation.isPending} > {isFavorite ? '★' : '☆'} ), }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [navigation, name, isFavorite, addMutation.isPending, removeMutation.isPending]); const scenesQuery = useQuery({ queryKey: ['performer-scenes', id], queryFn: () => client.listScenes({ performer_ids: [id], sort: 'release_date', per_page: 200, // Sceny TPDB/StashDB bez tube-linków nie są usable — zawsze filtrujemy. has_playback: true, }), }); 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(() => { const items = scenesQuery.data?.items ?? []; if (!seenSince) return items; const newOnes: SceneOut[] = []; const rest: SceneOut[] = []; for (const s of items) { if (s.created_at && s.created_at > seenSince) { newOnes.push(s); } else { rest.push(s); } } return [...newOnes, ...rest]; }, [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]); const tabRow = ( handleTabSwitch('scenes')} /> handleTabSwitch('movies')} /> ); 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} numColumns={2} // 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 }) => ( )} columnWrapperStyle={styles.gridRow} refreshing={scenesQuery.isRefetching} onRefresh={scenesQuery.refetch} ListHeaderComponent={ {scenesQuery.data ? `${scenesTotal}${scenesSuffix} ${scenesTotal === 1 ? 'scene' : 'scenes'}` : ' '} {/* Bug-report 2026-05-17 (f3f019d0): "elementy obsługowe zajmują za dużo ekranu" + 1a4bf258: "Re-scrape mógłby zniknąć, za to tagi/ kategorie by mogły". Re-scrape (dev-only bulk enrich) usunięty; Search zostaje, pod nim chipsy top-tagów performera (tap → TagScenes). */} refreshMutation.mutate()} disabled={refreshMutation.isPending} > {refreshMutation.isPending ? ( ) : ( ↻ Search more scenes )} {tab === 'scenes' && !!tagsQuery.data?.items.length && ( {tagsQuery.data.items.map((t) => ( navigation.push('TagScenes', { slug: t.slug, name: t.name }) } > {t.name} ))} )} } 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} ListHeaderComponent={ {moviesQuery.data ? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}` : ' '} } 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 }} /> )} ); } function TabButton({ label, count, active, onPress, }: { label: string; count: number; active: boolean; onPress: () => void; }) { return ( {label} {count > 0 ? ( {count} ) : null} ); } 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', }, actionBtnText: { color: theme.muted, fontWeight: '600', fontSize: 12 }, actionBtnTextPrimary: { color: theme.accent, fontWeight: '700', fontSize: 12 }, tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginHorizontal: 4, marginBottom: 10, }, tagChip: { backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, borderRadius: 14, paddingVertical: 4, paddingHorizontal: 10, maxWidth: 160, }, tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' }, tabRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, tabBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 8, borderRadius: 8, backgroundColor: theme.card, borderColor: theme.border, borderWidth: 1, }, tabBtnActive: { borderColor: theme.accent, backgroundColor: theme.bgElevated }, tabBtnText: { color: theme.muted, fontWeight: '600', fontSize: 14 }, tabBtnTextActive: { color: theme.fg }, tabCount: { backgroundColor: theme.border, paddingHorizontal: 6, paddingVertical: 1, borderRadius: 4, }, tabCountText: { color: theme.muted, fontWeight: '700', fontSize: 11 }, });