goon/mobile/src/screens/ScenesScreen.tsx
goon-foss 17e4ecaa8a fix(ui): jeden wspolny zestaw kontrolek naglowka zamiast kopii per ekran
Zgloszenie 2026-08-18 (cztery zrzuty obok siebie): "rozne odstepy miedzy zakladkami,
kazda zakladka wyglada inaczej, rozne wielkosci text fields, rozne obramowki, chyba
nawet inne czcionki".

Przyczyna strukturalna: kazdy ekran-lista mial WLASNA kopie stylow search/toolbar/
przycisku filtra. Zmierzony dryf: rog pola 12 vs 10, ramka 1,5 vs 1, font 16 vs 14,
odstep toolbara 12 vs 8, dwa promienie pigulek (8 i 12), padding kontenera 16/16 vs
16/12 vs 0. "Inne czcionki" = etykiety mialy fontWeight:'700' BEZ fontFamily, a RN nie
syntezuje pogrubienia dla fontu custom -> chudy Regular zamiast bold.

Nowy `src/ui.tsx`: SearchInput, PillButton, Toolbar + wspolne stale SCREEN_PAD_*.
Jedna wysokosc kontrolek w wierszu, jeden promien, jedna ramka, waga przez fontFamily
(nie fontWeight), stan aktywny robi kolor. Scenes/Movies/Sites przepiete na to, ich
lokalne duplikaty stylow usuniete (w tym osierocony SegButton w Sites). JAV i Anime
reuzywaja ScenesScreen, wiec dostaja to samo za darmo.

Zweryfikowane na emulatorze: cztery zakladki jeden przy drugim maja identyczne pole,
przycisk, pigulki i marginesy. 403 na liscie to build debug spoza allowlisty, nie UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:42:32 +02:00

544 lines
19 KiB
TypeScript

import { useNavigation, useRoute } 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,
View,
} from 'react-native';
import { Image } from 'expo-image';
import * as Haptics from 'expo-haptics';
import { MigrationBanner } from '../components/MigrationBanner';
import { PillButton, SCREEN_PAD_H, SCREEN_PAD_TOP, SearchInput, Toolbar } from '../ui';
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({
jav: javProp,
vertical: verticalProp,
}: {
/** Nadpisuje param trasy. HomeScreen renderuje ten ekran BEZ nawigacji (zakładki to
* stan lokalny, nie osobne trasy), więc wertykał przychodzi propsem, nie paramem. */
jav?: boolean;
vertical?: string;
} = {}) {
const client = useClient();
const { gridColumns } = usePreferences();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Scenes'>>();
// Zakładki wertykałów reużywają ten ekran: JAV przez `jav`, Anime przez
// `vertical='animation'`. Brak obu → główny feed, z którego backend odcina wertykały.
const route = useRoute();
const params = route.params as { jav?: boolean; vertical?: string } | undefined;
const jav = javProp ?? (params?.jav === true);
const vertical = verticalProp ?? params?.vertical;
// Wspólna nazwa wertykału do zapytań o tagi (stary `jav` też ma swój słownik).
const verticalKey = vertical ?? (jav ? 'jav' : undefined);
// Animacja NIE MA obsady i to nie brak danych, tylko wlasciwosc tresci: dla postaci
// rysowanych nie istnieje kanon, wiec zaden scraper nie ma czego przypisac (0 performerow
// na 12 698 scen, pomiar 2026-08-14). Przycisk prowadzilby do gwarantowanie pustej listy.
const hasCast = verticalKey !== 'animation';
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, jav, vertical],
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,
jav: jav || undefined,
vertical,
min_quality_p: filter.minQualityP > 0 ? filter.minQualityP : undefined,
// 0 = "Any" → undefined → backend stosuje bazowy próg 60s. >0 podnosi minimum.
// JAV nie ma duration (javflix nie podaje) → w sekcji JAV wyłączamy próg 60s
// (explicit 0), inaczej NULL >= 60 = false i cała sekcja byłaby pusta.
// JAV i animacja: część źródeł nie podaje długości (javflix, hentaigasm,
// hentaiplay) → NULL >= 60 = false wycięłoby całą sekcję. Explicit 0 wyłącza próg.
min_duration_sec: verticalKey
? 0
: 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;
},
});
// Dedup po id przy sklejaniu stron. OFFSET-paginacja + ciągły ingest = kolejne strony
// potrafią nałożyć się na siebie (nowe sceny zsuwają offset w dół), przez co ta sama
// scena wracała w feedzie przy scrollu (report 304c9258). Zostawiamy pierwsze
// wystąpienie (najświeższe). Backend ma teraz też deterministyczny tie-break (Scene.id).
const items = React.useMemo(() => {
const flat = data?.pages.flatMap((p) => p.items) ?? [];
const seen = new Set<string>();
return flat.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
}, [data?.pages]);
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}>
<Toolbar>
<SearchInput value={q} onChangeText={setQ} placeholder="search title…" />
{q.trim().length > 0 ? (
<PillButton
label={alreadySaved ? '★ Saved' : '☆ Save'}
active={alreadySaved}
onPress={() => {
const v = q.trim();
if (v && !alreadySaved) addSaved.mutate(v);
}}
/>
) : null}
<PillButton
label={`Filter${activeCount > 0 ? ` ${activeCount}` : ''}`}
active={activeCount > 0}
onPress={() => setFilterOpen(true)}
/>
</Toolbar>
{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}>
{hasCast ? (
<PillButton
label="Performers"
tone="secondary"
compact
onPress={() =>
navigation.navigate('Performers', verticalKey ? { vertical: verticalKey } : undefined)
}
/>
) : null}
<PillButton
label="Tags"
tone="secondary"
compact
onPress={() => navigation.navigate('Tags', verticalKey ? { vertical: verticalKey } : undefined)}
/>
<FavoritesButton />
</View>
{error instanceof Error && <Text style={styles.error}>{error.message}</Text>}
{/* Nad listą, nie w ListHeaderComponent: tam znikałby przy wyszukiwaniu i odjeżdżał
przy scrollu, a to komunikat, którego przegapienie kosztuje usera ulubione. */}
<MigrationBanner />
{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}
vertical={verticalKey}
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 (
<PillButton
label={`★ ${hasNew ? newTotal : ''}`.trim()}
tone="secondary"
compact
active={hasNew}
onPress={() => navigation.navigate('Favorites')}
/>
);
}
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: SCREEN_PAD_H, paddingTop: SCREEN_PAD_TOP },
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.accentText, backgroundColor: `${theme.accent}1A` },
savedChipText: { color: theme.fg, fontSize: 13 },
savedChipTextActive: { color: theme.accentText, fontWeight: '600' },
subToolbar: { flexDirection: 'row', gap: 8, marginBottom: 16 },
subButtonActive: {
backgroundColor: theme.accent,
borderColor: theme.accentText,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 6,
elevation: 3,
},
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.accentText, 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.accentText, 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.accentText,
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.accentText },
// 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 },
});