goon/mobile/src/components/SceneTile.tsx
goon-foss bbc2059728 feat(ui): badge jakosci i nazwa serwisu na kafelku sceny
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>
2026-08-18 12:41:16 +02:00

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 },
};
}