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>
278 lines
9.2 KiB
TypeScript
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 },
|
|
};
|
|
}
|