Kierunek A z makiet (miniaturki niosa ekran, chrome cichy) + jeden element z C: jakosc i zrodlo widoczne wprost na kafelku. Backend musial to najpierw WYSTAWIC, bo lista jedzie w trybie `light`, ktory okrawa playback_sources do samej miniatury: - `SceneOut.max_quality_p` (kolumna z migracji 0029, dotad uzywana tylko jako filtr), - slim wpis niesie PRAWDZIWY `origin` zrodla wybranej miniatury zamiast "". Kafelek: badge jakosci w LEWYM dolnym rogu (czas zostaje w prawym, ten sam ciezar wizualny), a w linii meta studio + przygaszona nazwa serwisu. Badge pokazujemy TYLKO gdy jakosc jest znana (6,5% scen), bo ma byc faktem, nie zgadywaniem. Zweryfikowane na emulatorze na PRAWDZIWYCH danych: zeby to bylo mozliwe, zbudowalem build debugowy podpisany kluczem release (przechodzi kontrole X-App-Signature, a jako debug nie ma FLAG_SECURE, wiec da sie zrobic zrzut). Widac "4K" i "1280p" w rogu oraz "ONLY FANS SXYPRN" w linii meta. Tymczasowa zmiana podpisu w build.gradle cofnieta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379 lines
13 KiB
TypeScript
379 lines
13 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 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:
|
|
* <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 { 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<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 = 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 (
|
|
<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}
|
|
{qLabel ? (
|
|
<View style={styles.qualBadge}>
|
|
<Text style={styles.qualText}>{qLabel}</Text>
|
|
</View>
|
|
) : 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 ? (
|
|
<Pressable
|
|
style={styles.playBtn}
|
|
hitSlop={6}
|
|
onPress={() => navigation.navigate('SceneDetail', { id: scene.id, autoplay: true })}
|
|
>
|
|
<Text style={styles.playBtnIcon}>▶</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
{/* 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. */}
|
|
<Text style={[styles.title, dim && styles.titleDim]} numberOfLines={2}>
|
|
{secondLine === 'studio'
|
|
? titleWithoutStudio(scene.title, scene.studio?.name)
|
|
: scene.title}
|
|
</Text>
|
|
{meta || srcLabel ? (
|
|
<View style={styles.metaRow}>
|
|
{meta ? (
|
|
<Text style={[styles.meta, styles.metaMain]} numberOfLines={1}>
|
|
{meta}
|
|
</Text>
|
|
) : null}
|
|
{srcLabel ? (
|
|
<Text style={[styles.meta, styles.metaSource]} numberOfLines={1}>
|
|
{srcLabel}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
) : 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.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 },
|
|
};
|
|
}
|