goon/mobile/src/screens/PerformerScenesScreen.tsx
goon-foss 7ed878ce16 feat(mobile): gorne belki w jednym wierszu, pasek w Edge chowa sie przy przewijaniu
- 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>
2026-09-15 15:37:53 +02:00

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' },
});