- SegmentTabs w ui.tsx: zakladki w wierszu narzedzi obok ikon - Ulubione, performerka, studio, Performers i Tags: jeden wiersz pod naglowkiem zamiast trzech-czterech (etykieta z licznikiem, zakladki, podpowiedz, sortowanie) - gwiazdka na ekranie performerki i studia bierze /favorites?counts=false - Edge: kolejny klip chowa pasek, cofniecie pokazuje; podpis, przyciski i postep jada razem z paskiem; FAB zgloszen ukryty, lezal na "Not interested" - changelog 2026-09-15c/d Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
434 lines
18 KiB
TypeScript
434 lines
18 KiB
TypeScript
// Sceny + filmy dla wybranego performera. Tab "Scenes" / "Movies".
|
|
// Backend filtruje przez `performer_ids=<id>` 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<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>();
|
|
const route = useRoute<RouteProp<RootStackParamList, 'PerformerScenes'>>();
|
|
const { id, name, seenSince, vertical } = route.params;
|
|
|
|
const [tab, setTab] = React.useState<Tab>('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: () => (
|
|
<View style={{ flexDirection: 'row', gap: 4, alignItems: 'center' }}>
|
|
<HeaderIconButton icon="eye-off-outline" muted accessibilityLabel="Hide" onPress={onHide} />
|
|
<HeaderIconButton
|
|
icon={isFavorite ? 'star' : 'star-outline'}
|
|
active={isFavorite}
|
|
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
|
onPress={() => (isFavorite ? removeMutation.mutate() : addMutation.mutate())}
|
|
disabled={addMutation.isPending || removeMutation.isPending}
|
|
/>
|
|
</View>
|
|
),
|
|
});
|
|
// 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<FilterState>(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<SceneOut[]>(
|
|
() => 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 = (
|
|
<Toolbar>
|
|
<SegmentTabs
|
|
tabs={[
|
|
{ key: 'scenes', label: 'Scenes', count: scenesQuery.data ? `${scenesTotal}${scenesSuffix}` : undefined },
|
|
{ key: 'movies', label: 'Movies', count: moviesTotal },
|
|
]}
|
|
value={tab}
|
|
onChange={handleTabSwitch}
|
|
/>
|
|
{tab === 'scenes' ? (
|
|
<>
|
|
{refreshMutation.isPending ? (
|
|
<ActivityIndicator color={theme.accentText} style={{ width: 44 }} />
|
|
) : (
|
|
<IconButton
|
|
icon="magnify-scan"
|
|
accessibilityLabel="Search more scenes"
|
|
onPress={() => refreshMutation.mutate()}
|
|
/>
|
|
)}
|
|
<IconButton
|
|
icon="filter-variant"
|
|
accessibilityLabel="Filters"
|
|
active={filterActiveCount > 0}
|
|
badge={filterActiveCount}
|
|
onPress={() => setFilterOpen(true)}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</Toolbar>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* 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 && <ActivityIndicator color={theme.fg} />}
|
|
{scenesQuery.error instanceof Error && (
|
|
<Text style={styles.error}>{scenesQuery.error.message}</Text>
|
|
)}
|
|
<FlatList
|
|
{...sceneGridProps(gridColumns)}
|
|
key={`scenes-list-${gridColumns}`}
|
|
data={sortedScenes}
|
|
keyExtractor={(s) => 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 }) => (
|
|
<SceneTile scene={item} seenSince={seenSince} secondLine="studio" />
|
|
)}
|
|
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 ? <Text style={styles.muted}>no scenes</Text> : 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`.
|
|
<ErrorBoundary scope="perfscenes-movies">
|
|
{moviesQuery.isLoading && <ActivityIndicator color={theme.fg} />}
|
|
{moviesQuery.error instanceof Error && (
|
|
<Text style={styles.error}>{moviesQuery.error.message}</Text>
|
|
)}
|
|
<FlatList
|
|
// Explicit key wymusza clean remount przy tab switch, bez tego React
|
|
// próbuje reuse'ować VirtualizedList z poprzedniej tab (numColumns=1
|
|
// dla scenes vs numColumns=2 dla movies), co na Androidzie czasem
|
|
// wywala native ViewGroup lifecycle.
|
|
key="movies-list"
|
|
data={movies}
|
|
numColumns={MOVIE_COLS}
|
|
removeClippedSubviews={false}
|
|
keyExtractor={(m) => 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 (
|
|
<MoviePosterCard
|
|
movie={item}
|
|
onPress={() => 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 ? (
|
|
<Text style={styles.muted}>no movies for this performer</Text>
|
|
) : 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 }}
|
|
/>
|
|
</ErrorBoundary>
|
|
)}
|
|
<ScenesFilterModal
|
|
visible={filterOpen}
|
|
initial={filter}
|
|
vertical={vertical ?? undefined}
|
|
performersLabel="Also featuring"
|
|
onApply={(next) => {
|
|
setFilter(next);
|
|
setFilterOpen(false);
|
|
}}
|
|
onClose={() => setFilterOpen(false)}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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' },
|
|
});
|