/** * 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: * } * /> */ 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>(); 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 ( {isPending ? ( DUPLIKAT ) : null} {scene.is_favorite ? ( ) : null} {isNew ? ( NEW ) : null} {dim ? ( ) : null} {durLabel ? ( {durLabel} ) : null} {scene.title} {meta ? ( {meta} ) : null} ); } // 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 }, }; }