goon/mobile/src/screens/ScenesScreen.tsx
jtrzupek 567a8fb3b5 fix(mobile): scene-list scroll perf + native phone-side fpoxxx resolver
(1) Scroll jank/device load on long scene lists (report 5b7ca1e1): SceneTile is now React.memo'd so typing in search no longer re-renders every mounted tile, and sceneGridProps bounds the render window (windowSize 7 etc.) — required because removeClippedSubviews stays false to avoid thumbnail blanking. Applies to all scene grids. (2) fpoxxx played an ad instead of the video via the WebView fallback (reports f79beefb/cfa207c7). fpoxxx is KVS with an IP-bound + session-bound get_file token (cross-IP 403 confirmed), so it must resolve phone-side: new fpoxxxResolver fetches the page + follows get_file on the device (KVS real_url port for the function/0 case), wired into SceneDetailScreen like sxyprn/eporner. Verified from a residential IP: get_file -> CDN returns 206 video/mp4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:02:21 +02:00

543 lines
18 KiB
TypeScript

import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics';
import { SceneTile, sceneGridProps } from '../components/SceneTile';
import { SceneGridSkeleton } from '../components/SceneGridSkeleton';
import { Thumb } from '../components/Thumb';
import { useClient } from '../ClientContext';
import { usePreferences } from '../PreferencesContext';
import { theme } from '../theme';
import type { RootStackParamList } from '../navigation';
import type { SceneOut } from '../types';
import { DEFAULT_FILTER, FilterState, ScenesFilterModal } from './ScenesFilterModal';
export function ScenesScreen() {
const client = useClient();
const { gridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [filter, setFilter] = useState<FilterState>(DEFAULT_FILTER);
const [filterOpen, setFilterOpen] = useState(false);
React.useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 350);
return () => clearTimeout(t);
}, [q]);
const PER_PAGE = 50;
const {
data,
isLoading,
error,
refetch,
isRefetching,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['scenes', debouncedQ, filter],
queryFn: ({ pageParam = 1 }) =>
client.listScenes({
q: debouncedQ || undefined,
tags: filter.tagSlugs.length ? filter.tagSlugs : undefined,
studio_slugs: filter.studioSlugs.length ? filter.studioSlugs : undefined,
performer_ids: filter.performerIds.length ? filter.performerIds : undefined,
has_playback: filter.hasPlayback || undefined,
sort: filter.sort,
include_stubs: filter.includeStubs || undefined,
origin: filter.origin.trim() || undefined,
// 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum.
min_duration_sec: filter.minDurationSec > 0 ? filter.minDurationSec : undefined,
page: pageParam,
per_page: PER_PAGE,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
// Paginuj po has_more (źródło prawdy z fetcha per_page+1). `total` jest dla
// list filtrowanych bounded ("1000+"), więc NIE nadaje się do liczenia stron.
// Fallback na loaded<total gdy stary backend nie zwraca has_more.
const more =
lastPage.has_more ?? lastPage.page * lastPage.per_page < lastPage.total;
return more ? lastPage.page + 1 : undefined;
},
});
const items = data?.pages.flatMap((p) => p.items) ?? [];
const total = data?.pages[0]?.total ?? 0;
// total bywa bounded ("1000+") dla list filtrowanych (q/tag) — patrz backend _COUNT_CAP.
const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`;
const activeCount =
filter.tagSlugs.length +
filter.studioSlugs.length +
filter.performerIds.length +
(filter.hasPlayback ? 1 : 0) +
(filter.origin.trim() ? 1 : 0);
// Saved searches — zapisane słowa kluczowe (user-report mobilism).
const queryClient = useQueryClient();
const savedQuery = useQuery({
queryKey: ['saved-searches'],
queryFn: () => client.listSavedSearches(),
});
const saved = savedQuery.data ?? [];
const addSaved = useMutation({
mutationFn: (query: string) => client.addSavedSearch(query),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }),
});
const removeSaved = useMutation({
mutationFn: (id: string) => client.removeSavedSearch(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['saved-searches'] }),
});
const applySaved = (query: string) => {
setQ(query);
setDebouncedQ(query); // natychmiast, bez czekania na debounce
};
const alreadySaved = saved.some((s) => s.query === q.trim());
return (
<View style={styles.container}>
<View style={styles.toolbar}>
<TextInput
style={styles.search}
value={q}
onChangeText={setQ}
placeholder="search title…"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
{q.trim().length > 0 ? (
<Pressable
onPress={() => {
const v = q.trim();
if (v && !alreadySaved) addSaved.mutate(v);
}}
disabled={alreadySaved}
style={[styles.toolbarButton, alreadySaved && styles.toolbarButtonActive]}
>
<Text
style={[styles.toolbarButtonText, alreadySaved && { color: theme.accent }]}
>
{alreadySaved ? '★ Saved' : '☆ Save'}
</Text>
</Pressable>
) : null}
<Pressable
onPress={() => setFilterOpen(true)}
style={[styles.toolbarButton, activeCount > 0 && styles.toolbarButtonActive]}
>
<Text style={[styles.toolbarButtonText, activeCount > 0 && { color: theme.accent }]}>
Filter{activeCount > 0 ? ` ${activeCount}` : ''}
</Text>
</Pressable>
</View>
{saved.length > 0 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.savedRow}
contentContainerStyle={styles.savedRowContent}
>
{saved.map((s) => {
const active = debouncedQ === s.query;
return (
<Pressable
key={s.id}
style={[styles.savedChip, active && styles.savedChipActive]}
onPress={() => applySaved(s.query)}
onLongPress={() =>
Alert.alert('Remove saved search', `Remove "${s.query}"?`, [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeSaved.mutate(s.id),
},
])
}
>
<Text
style={[styles.savedChipText, active && styles.savedChipTextActive]}
numberOfLines={1}
>
{s.query}
</Text>
</Pressable>
);
})}
</ScrollView>
) : null}
<View style={styles.subToolbar}>
<Pressable
style={styles.subButton}
onPress={() => navigation.navigate('Performers')}
>
<Text style={styles.subButtonText}>Performers</Text>
</Pressable>
<Pressable
style={styles.subButton}
onPress={() => navigation.navigate('Tags')}
>
<Text style={styles.subButtonText}>Tags</Text>
</Pressable>
<FavoritesButton />
</View>
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
{isLoading ? (
<SceneGridSkeleton count={8} />
) : (
<FlatList
{...sceneGridProps(gridColumns)}
data={items}
keyExtractor={(s) => s.id}
// removeClippedSubviews + okno renderowania (windowSize itd.) idą z sceneGridProps
// — patrz SceneTile. Perf bug-report 5b7ca1e1 (jank/obciążenie przy długim scrollu).
renderItem={({ item }) => <SceneTile scene={item} />}
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null}
refreshing={isRefetching}
onRefresh={refetch}
onEndReached={() => {
if (hasNextPage && !isFetchingNextPage) fetchNextPage();
}}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingNextPage ? (
<ActivityIndicator color={theme.muted} style={{ marginVertical: 18 }} />
) : !hasNextPage && items.length > 0 ? (
<Text style={styles.muted}>{`${items.length} / ${totalLabel}`}</Text>
) : null
}
ListEmptyComponent={!isLoading ? <Text style={styles.muted}>no scenes</Text> : null}
contentContainerStyle={{ paddingBottom: 24 }}
/>
)}
<ScenesFilterModal
visible={filterOpen}
initial={filter}
onApply={(next) => {
setFilter(next);
setFilterOpen(false);
}}
onClose={() => setFilterOpen(false)}
/>
</View>
);
}
function ContinueWatchingRail() {
const client = useClient();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
const { data } = useQuery({
queryKey: ['watch-recent'],
queryFn: () => client.listRecentWatch(10),
staleTime: 30_000,
});
const items = data?.items ?? [];
if (items.length === 0) return null;
return (
<View style={styles.rail}>
<Text style={styles.railLabel}>Continue watching</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.railContent}
>
{items.map((entry) => {
const scene = entry.scene;
const thumb = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const dur = entry.duration_sec || scene.duration_sec || 0;
const pct = dur > 0 ? Math.min(100, Math.round((entry.position_sec / dur) * 100)) : 0;
return (
<Pressable
key={scene.id}
style={styles.railItem}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
>
<Thumb url={thumb} style={styles.railThumb} />
{pct > 0 ? (
<View style={styles.railProgressBg}>
<View style={[styles.railProgressFg, { width: `${pct}%` }]} />
</View>
) : null}
<Text style={styles.railTitle} numberOfLines={2}>
{scene.title}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
function FavoritesButton() {
const client = useClient();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
const { data } = useQuery({
queryKey: ['favorites'],
queryFn: () => client.listFavorites(),
refetchOnWindowFocus: true,
staleTime: 30_000,
});
const newTotal = data?.new_total ?? 0;
const hasNew = newTotal > 0;
return (
<Pressable
style={[styles.subButton, hasNew && styles.subButtonActive]}
onPress={() => navigation.navigate('Favorites')}
>
<Text style={[styles.subButtonText, hasNew && styles.subButtonTextActive]}>
{hasNew ? newTotal : ''}
</Text>
</Pressable>
);
}
function SceneRow({ scene }: { scene: SceneOut }) {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
const [isPreviewing, setIsPreviewing] = useState(false);
const performers = scene.performers
.slice(0, 3)
.map((p) => p.canonical_name)
.join(', ');
const animatedUrl = scene.playback_sources.find((s) => s.animated_thumbnail_url)
?.animated_thumbnail_url;
const staticUrl = scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const displayUrl = isPreviewing && animatedUrl ? animatedUrl : staticUrl ?? animatedUrl;
const startPreview = () => {
if (!animatedUrl) return;
setIsPreviewing(true);
Haptics.selectionAsync().catch(() => {});
};
// Watched indicator: finished=True dim'uje cały kafelek; pośredni progress (>0,
// <finished) trzymamy bez dim'a żeby user widział "częściowo obejrzane" ale wraca chętnie.
const dim = scene.finished === true;
return (
<Pressable
style={[styles.row, dim && styles.rowDimmed]}
onPress={() => navigation.navigate('SceneDetail', { id: scene.id })}
onLongPress={startPreview}
onPressOut={() => setIsPreviewing(false)}
delayLongPress={180}
>
<Thumb url={displayUrl} style={styles.thumbnail} />
{scene.is_favorite ? (
<View style={styles.favBadge}>
<Text style={styles.favBadgeText}></Text>
</View>
) : null}
<View style={styles.rowContent}>
<Text style={styles.rowTitle} numberOfLines={1}>
{scene.title}
</Text>
{scene.release_date || scene.studio ? (
<Text style={styles.rowMuted} numberOfLines={1}>
{[scene.release_date, scene.studio?.name].filter(Boolean).join(' · ')}
</Text>
) : null}
{performers ? (
<Text style={styles.rowMuted} numberOfLines={1}>
{performers}
{scene.performers.length > 3 ? ` +${scene.performers.length - 3}` : ''}
</Text>
) : null}
<Text style={styles.rowSources}>
{[...new Set(scene.external_refs.map((r) => r.source))].join(' · ')}
{scene.playback_sources.length > 0
? `${scene.playback_sources.length}`
: ''}
{dim ? ' ✓ watched' : ''}
</Text>
</View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg, paddingHorizontal: 16, paddingTop: 16 },
toolbar: { flexDirection: 'row', gap: 12, marginBottom: 12 },
search: {
flex: 1,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 12,
color: theme.fg,
padding: 12,
fontSize: 16,
},
toolbarButton: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 12,
paddingHorizontal: 16,
justifyContent: 'center',
},
toolbarButtonActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` },
toolbarButtonText: { color: theme.fg, fontWeight: '700' },
savedRow: { flexGrow: 0, marginBottom: 12 },
savedRowContent: { gap: 8, paddingRight: 12 },
savedChip: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 6,
maxWidth: 200,
},
savedChipActive: { borderColor: theme.accent, backgroundColor: `${theme.accent}1A` },
savedChipText: { color: theme.fg, fontSize: 13 },
savedChipTextActive: { color: theme.accent, fontWeight: '600' },
subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 },
subButton: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 8,
},
subButtonActive: {
backgroundColor: theme.accent,
borderColor: theme.accent,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 6,
elevation: 3,
},
subButtonText: { color: theme.accentSecondary, fontSize: 13, fontWeight: '700' },
subButtonTextActive: { color: theme.fg },
row: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 12,
padding: 12,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
rowDimmed: { opacity: 0.45 },
favBadge: {
position: 'absolute',
top: 6,
left: 6,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 8,
},
favBadgeText: { color: theme.accent, fontSize: 12, fontWeight: '700' },
thumbnail: {
width: 100,
height: 56,
borderRadius: 8,
backgroundColor: theme.border,
},
rowContent: {
flex: 1,
},
rowTitle: { color: theme.fg, fontWeight: '700', fontSize: 16, marginBottom: 4 },
rowMuted: { color: theme.muted, fontSize: 14, marginTop: 2 },
rowSources: { color: theme.accent, fontSize: 12, marginTop: 8, textTransform: 'uppercase', fontWeight: '600' },
muted: { color: theme.muted, textAlign: 'center', marginTop: 32, fontSize: 16 },
error: { color: theme.bad, padding: 16 },
chip: {
borderColor: theme.border,
borderWidth: 1.5,
borderRadius: 18,
paddingHorizontal: 14,
paddingVertical: 7,
backgroundColor: theme.card,
},
chipActive: {
borderColor: theme.accent,
backgroundColor: `${theme.accent}26`,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 6,
elevation: 2,
},
chipText: { color: theme.muted, fontSize: 13, fontWeight: '700' },
chipTextActive: { color: theme.accent },
// Continue watching rail
rail: { marginBottom: 16 },
railLabel: {
color: theme.muted,
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontWeight: '700',
marginBottom: 8,
paddingLeft: 2,
},
railContent: { gap: 10, paddingRight: 8 },
railItem: { width: 160 },
railThumb: {
width: 160,
height: 90,
borderRadius: 8,
backgroundColor: theme.bgElevated,
},
railThumbPlaceholder: {
borderColor: theme.border,
borderWidth: 1,
},
railProgressBg: {
height: 3,
backgroundColor: theme.bgElevated,
borderRadius: 2,
marginTop: 4,
overflow: 'hidden',
},
railProgressFg: { height: 3, backgroundColor: theme.accent },
railTitle: {
color: theme.fg,
fontSize: 12,
fontWeight: '600',
marginTop: 6,
lineHeight: 15,
},
// 2-col grid wrapper — SceneTile sam ma styling tile/thumb/etc, tu tylko
// odstęp między tilami w wierszu i pod wierszem.
gridRow: { gap: 10, marginBottom: 14 },
});