goon/mobile/src/components/SceneTile.tsx
jtrzupek 6b972d36ec fix(mobile): anchor scene lists so prepended scenes don't jump the scroll
Lists sort by created_at desc, so scrapers add new scenes at the top; on refetch those
prepend above the viewport and the whole list slid down under the user's thumb (jitter/
tearing while browsing filtered results). Added maintainVisibleContentPosition to the
shared sceneGridProps() so RN pins the visible item and corrects the offset; a small
autoscrollToTopThreshold still surfaces new items when the user is at the very top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:37:19 +02:00

278 lines
9.2 KiB
TypeScript

/**
* SceneTile — 2-col 16:9 grid item używany w listach scen
* (Scenes, SiteScenes, PerformerScenes, StudioScenes, TagScenes).
*
* Per impeccable.style/slop + Jan feedback "większe miniaturki, mniej tekstu":
* - Thumb wypełnia szerokość kolumny (aspect 16:9)
* - Title 1 linijka, weight 600, letter-spacing -0.2
* - Pod tytułem: 1 linia uppercase micro-meta (studio | performers | tag — wybór per ekran)
* - Overlay na thumb: fav (top-left), duration (bottom-right), NEW (top-right gdy seenSince),
* ✓watched (top-right gdy finished)
* - Long-press → animated preview (gdy playback_source ma animated_thumbnail_url)
*
* Zostawia Pressable do parenta dla custom onLongPress (np. delete-from-favorites) —
* default onLongPress robi preview (jak w ScenesScreen).
*
* Used inline w 2-column FlatList:
* <FlatList
* numColumns={2}
* columnWrapperStyle={{ gap: 10, marginBottom: 14 }}
* renderItem={({ item }) => <SceneTile scene={item} secondLine="studio" />}
* />
*/
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useSceneActions } from '../SceneActionsContext';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
import type { SceneOut } from '../types';
import { Thumb } from './Thumb';
export type SecondLine = 'studio' | 'performers' | 'date' | 'none';
interface Props {
scene: SceneOut;
secondLine?: SecondLine;
/**
* Pokazuj NEW badge gdy scene.created_at > seenSince. Używane na ekranach
* Performer/Studio scenes — gdy user owner'ował fav, last_seen_at z favorite
* jest porównywany ze sceną.
*/
seenSince?: string;
/**
* Custom long-press handler (np. removal from favorites). Default = animated
* preview gdy thumb ma animated wariant.
*/
onLongPress?: () => void;
}
function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions();
// animated_thumbnail_url używamy tylko jako still-fallback gdy brak statycznej
// (preview-on-hold usunięty — bug-report 5a6844db, gest nic nie robił).
const displayUrl =
scene.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url ??
scene.playback_sources.find((s) => s.animated_thumbnail_url)?.animated_thumbnail_url;
const isPending = pendingDuplicate?.id === scene.id;
const handlePress = () => {
// Tryb wyboru duplikatu: tap = wybierz oryginał (chyba że to ta sama kafelka).
if (isSelecting && !isPending) {
pickDuplicateTarget(scene);
return;
}
navigation.navigate('SceneDetail', { id: scene.id });
};
const handleLongPress = () => {
if (onLongPress) {
onLongPress();
return;
}
openActions(scene);
};
const dim = scene.finished === true;
const isNew = !!(seenSince && scene.created_at && scene.created_at > seenSince);
const dur = scene.duration_sec;
const durLabel =
dur && dur > 0
? dur >= 3600
? `${Math.floor(dur / 3600)}h${String(Math.floor((dur % 3600) / 60)).padStart(2, '0')}`
: `${Math.floor(dur / 60)}m`
: null;
const meta = (() => {
if (secondLine === 'none') return null;
if (secondLine === 'studio') return scene.studio?.name || null;
if (secondLine === 'performers') {
if (scene.performers.length === 0) return null;
const names = scene.performers.slice(0, 2).map((p) => p.canonical_name).join(', ');
return scene.performers.length > 2 ? `${names} +${scene.performers.length - 2}` : names;
}
if (secondLine === 'date') return scene.release_date;
return null;
})();
return (
<Pressable
style={styles.tile}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View
style={[
styles.thumbWrap,
dim && styles.thumbDim,
isSelecting && !isPending && styles.thumbSelectable,
isPending && styles.thumbPending,
]}
>
<Thumb url={displayUrl} style={styles.thumb} />
{isPending ? (
<View style={styles.dupBadge}>
<Text style={styles.dupBadgeText}>DUPLIKAT</Text>
</View>
) : null}
{scene.is_favorite ? (
<View style={styles.favBadge}>
<Text style={styles.favBadgeText}></Text>
</View>
) : null}
{isNew ? (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
</View>
) : null}
{dim ? (
<View style={styles.watchedBadge}>
<Text style={styles.watchedText}></Text>
</View>
) : null}
{durLabel ? (
<View style={styles.durBadge}>
<Text style={styles.durText}>{durLabel}</Text>
</View>
) : null}
</View>
<Text style={[styles.title, dim && styles.titleDim]} numberOfLines={1}>
{scene.title}
</Text>
{meta ? (
<Text style={styles.meta} numberOfLines={1}>
{meta}
</Text>
) : null}
</Pressable>
);
}
// Memoizowany — bez tego każda zmiana stanu parenta (np. pisanie w search-boxie na
// ScenesScreen) re-renderuje WSZYSTKIE zamontowane kafelki → jank klawiatury i scrolla
// (bug-report 5b7ca1e1). Props per item są stabilne (scene z react-query), więc shallow
// compare wystarcza.
export const SceneTile = React.memo(SceneTileBase);
const styles = StyleSheet.create({
tile: { flex: 1, marginBottom: 14 },
thumbWrap: {
width: '100%',
aspectRatio: 16 / 9,
borderRadius: 6,
overflow: 'hidden',
position: 'relative',
backgroundColor: theme.bgElevated,
},
thumb: { width: '100%', height: '100%' },
thumbDim: { opacity: 0.45 },
thumbSelectable: { borderWidth: 2, borderColor: theme.accent, opacity: 0.85 },
thumbPending: { borderWidth: 2, borderColor: theme.fg, opacity: 0.55 },
dupBadge: {
position: 'absolute',
top: 6,
left: 6,
backgroundColor: theme.accent,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
dupBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
favBadge: {
position: 'absolute',
top: 6,
left: 6,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 6,
},
favBadgeText: { color: theme.accent, fontSize: 11, fontWeight: '700' },
newBadge: {
position: 'absolute',
top: 6,
right: 6,
backgroundColor: theme.accent,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
newBadgeText: { color: theme.fg, fontSize: 9, fontWeight: '800', letterSpacing: 0.6 },
watchedBadge: {
position: 'absolute',
top: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.78)',
width: 20,
height: 20,
borderRadius: 999,
alignItems: 'center',
justifyContent: 'center',
},
watchedText: { color: theme.fg, fontSize: 11, fontWeight: '700' },
durBadge: {
position: 'absolute',
bottom: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.78)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
durText: {
color: theme.fg,
fontSize: 11,
fontFamily: fonts.mono,
fontVariant: ['tabular-nums'],
},
title: {
color: theme.fg,
fontSize: 14,
fontFamily: fonts.display,
marginTop: 8,
letterSpacing: -0.2,
},
titleDim: { color: theme.muted },
meta: {
color: theme.muted,
fontSize: 10,
fontFamily: fonts.mono,
marginTop: 3,
letterSpacing: 0.5,
textTransform: 'uppercase',
},
});
/**
* Props siatki scen dla danej liczby kolumn (1/2/3, z PreferencesContext).
* `key` wymusza remount FlatListy przy zmianie numColumns (RN nie wspiera zmiany w locie).
* columnWrapperStyle TYLKO gdy cols>1 — przy 1 kolumnie RN rzuca jeśli ustawione.
*/
export function sceneGridProps(cols: number) {
return {
key: `scenegrid-${cols}`,
numColumns: cols,
columnWrapperStyle: cols > 1 ? { gap: 10 } : undefined,
// Perf (bug-report 5b7ca1e1): removeClippedSubviews zostaje false (inaczej expo-image
// blankuje miniaturki po scrollu), więc okno renderowania ograniczamy ręcznie —
// bez tego długa lista trzyma setki kafelków z obrazami → jank + obciążenie telefonu.
// windowSize 7 ≈ ~3 ekrany w pamięci. Dotyczy wszystkich siatek scen.
removeClippedSubviews: false,
windowSize: 7,
maxToRenderPerBatch: 8,
initialNumToRender: 8,
updateCellsBatchingPeriod: 50,
// Anti-jank (report): sort=created_at desc → scrapery dosypują nowe sceny NA GÓRĘ.
// Gdy refetch wstawi je nad viewportem, treść skacze/tearing pod palcem. RN pinuje
// widoczny element (minIndexForVisible:0) i sam koryguje offset. autoscrollToTopThreshold:
// gdy user jest przy samej górze (<48px), nowe wskakują normalnie; niżej lista stoi.
maintainVisibleContentPosition: { minIndexForVisible: 0, autoscrollToTopThreshold: 48 },
};
}