Batch from user feedback: (1) Grid columns 1/2/3 setting (PreferencesContext, persisted) across all scene grids — default 2 was too small on phones. (2) Min-duration filter chips (5/10/20/30+ min) to hide ad-clips. (3) Saved-search chips + Save button (backed by /saved-searches). (4) Re-enabled screen-capture protection (Recents hide + screenshot block) for distributed users — verified active on emulator (screencap returns 0 bytes). (5) 'Checking for updates' gate before the PIN screen so a background OTA restart no longer causes a double PIN prompt. Changelog entry added. Published OTA runtime 1.1 (a9620b12). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
478 lines
18 KiB
TypeScript
478 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,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { useClient } from '../ClientContext';
|
|
import { FavoriteSceneRow } from '../components/FavoriteSceneRow';
|
|
import { MoviePosterCard } from '../components/MoviePosterCard';
|
|
import { SceneTile, sceneGridProps } from '../components/SceneTile';
|
|
import { usePreferences } from '../PreferencesContext';
|
|
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 { gridColumns } = usePreferences();
|
|
const queryClient = useQueryClient();
|
|
const navigation =
|
|
useNavigation<NativeStackNavigationProp<RootStackParamList, 'PerformerScenes'>>();
|
|
const route = useRoute<RouteProp<RootStackParamList, 'PerformerScenes'>>();
|
|
const { id, name, seenSince } = route.params;
|
|
|
|
const [tab, setTab] = React.useState<Tab>('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: () => (
|
|
<View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
|
|
<Pressable onPress={onHide} hitSlop={10}>
|
|
<Text style={{ color: theme.mutedDim, fontSize: 18 }}>🚫</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => (isFavorite ? removeMutation.mutate() : addMutation.mutate())}
|
|
hitSlop={12}
|
|
disabled={addMutation.isPending || removeMutation.isPending}
|
|
>
|
|
<Text style={{ color: isFavorite ? theme.accent : theme.muted, fontSize: 22 }}>
|
|
{isFavorite ? '★' : '☆'}
|
|
</Text>
|
|
</Pressable>
|
|
</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.
|
|
const [selectedTag, setSelectedTag] = React.useState<{ slug: string; name: string } | null>(null);
|
|
|
|
const scenesQuery = useQuery({
|
|
queryKey: ['performer-scenes', id, selectedTag?.slug ?? null],
|
|
queryFn: () =>
|
|
client.listScenes({
|
|
performer_ids: [id],
|
|
tags: selectedTag ? [selectedTag.slug] : undefined,
|
|
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<SceneOut[]>(() => {
|
|
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 = (
|
|
<View style={styles.tabRow}>
|
|
<TabButton
|
|
label="Scenes"
|
|
count={scenesTotal}
|
|
active={tab === 'scenes'}
|
|
onPress={() => handleTabSwitch('scenes')}
|
|
/>
|
|
<TabButton
|
|
label="Movies"
|
|
count={moviesTotal}
|
|
active={tab === 'movies'}
|
|
onPress={() => handleTabSwitch('movies')}
|
|
/>
|
|
</View>
|
|
);
|
|
|
|
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={scenesQuery.refetch}
|
|
ListHeaderComponent={
|
|
<View>
|
|
<View style={styles.header}>
|
|
<Text style={styles.subtitle}>
|
|
{scenesQuery.data
|
|
? `${scenesTotal}${scenesSuffix} ${scenesTotal === 1 ? 'scene' : 'scenes'}`
|
|
: ' '}
|
|
</Text>
|
|
</View>
|
|
{/* 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). */}
|
|
<View style={styles.actionRow}>
|
|
<Pressable
|
|
style={[
|
|
styles.actionBtn,
|
|
refreshMutation.isPending && styles.refreshBtnLoading,
|
|
]}
|
|
onPress={() => refreshMutation.mutate()}
|
|
disabled={refreshMutation.isPending}
|
|
>
|
|
{refreshMutation.isPending ? (
|
|
<ActivityIndicator color={theme.accent} size="small" />
|
|
) : (
|
|
<Text style={styles.actionBtnTextPrimary}>↻ Search more scenes</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
{tab === 'scenes' && !!tagsQuery.data?.items.length && (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.tagRow}
|
|
>
|
|
{tagsQuery.data.items.map((t) => {
|
|
const active = selectedTag?.slug === t.slug;
|
|
return (
|
|
<Pressable
|
|
key={t.id}
|
|
style={[styles.tagChip, active && styles.tagChipActive]}
|
|
onPress={() =>
|
|
setSelectedTag(active ? null : { slug: t.slug, name: t.name })
|
|
}
|
|
>
|
|
<Text
|
|
style={[styles.tagChipText, active && styles.tagChipTextActive]}
|
|
numberOfLines={1}
|
|
>
|
|
{active ? `${t.name} ✕` : t.name}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
}
|
|
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}
|
|
ListHeaderComponent={
|
|
<View style={styles.header}>
|
|
<Text style={styles.subtitle}>
|
|
{moviesQuery.data
|
|
? `${moviesTotal} ${moviesTotal === 1 ? 'film' : 'films'}`
|
|
: ' '}
|
|
</Text>
|
|
</View>
|
|
}
|
|
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>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function TabButton({
|
|
label,
|
|
count,
|
|
active,
|
|
onPress,
|
|
}: {
|
|
label: string;
|
|
count: number;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable style={[styles.tabBtn, active && styles.tabBtnActive]} onPress={onPress}>
|
|
<Text style={[styles.tabBtnText, active && styles.tabBtnTextActive]}>
|
|
{label}
|
|
</Text>
|
|
{count > 0 ? (
|
|
<View style={styles.tabCount}>
|
|
<Text style={styles.tabCountText}>{count}</Text>
|
|
</View>
|
|
) : null}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
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',
|
|
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.accent },
|
|
tagChipText: { color: theme.muted, fontSize: 12, fontWeight: '600' },
|
|
tagChipTextActive: { color: theme.fg, fontWeight: '700' },
|
|
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 },
|
|
});
|