/** * 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 do 2 linijek, weight 600, letter-spacing -0.2 (przy 1 linijce pełny tytuł * widziało tylko 14,3% scen, mediana tytułu to 46 znaków). Gdy druga linia to studio, * zdejmujemy jego nazwę z przodu tytułu, patrz `titleWithoutStudio`. * - 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 { isNewScene } from '../lib/newScenes'; 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; } /** * Tytuł bez nazwy studia sklejonej z przodu, bo studio stoi linijkę niżej. * * Dotyczy 8,2% scen (pomiar na 200 tys. grywalnych, 2026-08-13): „MYLF - Aria's MILF * Temptation" nad metą „MYLF", „Thicc 18 - POV Action…" nad „THICC 18". Powtórzenie zjada * znaki w miejscu, gdzie ich brakuje najbardziej: mediana tytułu to 46 znaków, a w kolumnie * mieści się ~24 na linię. * * Gdy po zdjęciu zostaje mniej niż 12 znaków, ODDAJEMY tytuł nietknięty — lepsze * powtórzenie niż ogryzek w rodzaju „Scene 1". */ /** 'tube:hqpornercom' → 'hqporner'. Kafelek pokazuje serwis, z którego jest miniatura, * żeby było widać skąd scena pochodzi bez wchodzenia w szczegóły (element z makiety C). */ function sourceLabel(origin?: string | null): string | null { if (!origin) return null; const raw = origin.replace(/^tube:/, '').trim(); if (!raw) return null; return raw.replace(/(com|net|org|xxx|tv|to)$/i, '') || raw; } /** 2160 → '4K', reszta → '1080p'. Pokazujemy TYLKO gdy jakość jest znana (6,5% scen), * bo badge ma być faktem, nie zgadywaniem. */ function qualityLabel(p?: number | null): string | null { if (!p || p < 720) return null; return p >= 2160 ? '4K' : `${p}p`; } function titleWithoutStudio(title: string, studio?: string | null): string { const t = title.trim(); const s = (studio || '').trim(); if (!s || t.toLowerCase().indexOf(s.toLowerCase()) !== 0) return t; const rest = t.slice(s.length).replace(/^[\s\-–—:|·]+/, ''); return rest.length >= 12 ? rest : t; } 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 = isNewScene(scene, 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 qLabel = qualityLabel(scene.max_quality_p); const srcLabel = sourceLabel(scene.playback_sources.find((x) => x.origin)?.origin); 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} {qLabel ? ( {qLabel} ) : null} {/* Quick-play (user-request): tap ▶ = od razu graj najlepsze źródło (bez wchodzenia na SceneDetail). Autoplay resolvuje top-source na mount; z ustawioną domyślną jakością to 1 tap → wideo. Tap poza ▶ = szczegóły. Ukryte w trybie wyboru dup. */} {!isSelecting ? ( navigation.navigate('SceneDetail', { id: scene.id, autoplay: true })} > ▶ ) : null} {/* Dwie linie zamiast jednej: przy jednej pełny tytuł widziało 14,3% scen, przy dwóch widzi 54,4%. Kafelek rośnie o ~18 px, miniaturka zostaje bez zmian. */} {secondLine === 'studio' ? titleWithoutStudio(scene.title, scene.studio?.name) : scene.title} {meta || srcLabel ? ( {meta ? ( {meta} ) : null} {srcLabel ? ( {srcLabel} ) : null} ) : 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.accentText, 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.accentText, 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' }, // Jakość w LEWYM dolnym rogu (czas jest w prawym), ten sam ciężar wizualny, żeby // miniaturka dalej niosła kafelek. Kierunek A: chrome ma być cichy. qualBadge: { position: 'absolute', bottom: 6, left: 6, backgroundColor: 'rgba(0,0,0,0.78)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, }, qualText: { color: theme.fg, fontSize: 10, fontFamily: fonts.mono, letterSpacing: 0.3 }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }, metaMain: { flexShrink: 1 }, metaSource: { color: theme.mutedDim }, durBadge: { position: 'absolute', bottom: 6, right: 6, backgroundColor: 'rgba(0,0,0,0.78)', paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, }, playBtn: { position: 'absolute', left: '50%', top: '50%', marginLeft: -18, marginTop: -18, width: 36, height: 36, borderRadius: 18, backgroundColor: 'rgba(0,0,0,0.5)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.35)', alignItems: 'center', justifyContent: 'center', }, playBtnIcon: { color: '#fff', fontSize: 15, marginLeft: 2 }, 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, 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 }, }; }