fix(mobile): scene-list scroll perf + native phone-side fpoxxx resolver

(1) Scroll jank/device load on long scene lists (report 5b7ca1e1): SceneTile is now React.memo'd so typing in search no longer re-renders every mounted tile, and sceneGridProps bounds the render window (windowSize 7 etc.) — required because removeClippedSubviews stays false to avoid thumbnail blanking. Applies to all scene grids. (2) fpoxxx played an ad instead of the video via the WebView fallback (reports f79beefb/cfa207c7). fpoxxx is KVS with an IP-bound + session-bound get_file token (cross-IP 403 confirmed), so it must resolve phone-side: new fpoxxxResolver fetches the page + follows get_file on the device (KVS real_url port for the function/0 case), wired into SceneDetailScreen like sxyprn/eporner. Verified from a residential IP: get_file -> CDN returns 206 video/mp4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-19 11:02:21 +02:00
parent e4cb94bc59
commit 567a8fb3b5
5 changed files with 178 additions and 5 deletions

View file

@ -16,6 +16,14 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-06-19',
date: 'June 2026',
items: [
'Smoother scrolling on long scene lists — no more lag or phone slowdown.',
'fpo.xxx scenes now play the actual video instead of an ad.',
],
},
{ {
id: '2026-06-16', id: '2026-06-16',
date: 'June 2026', date: 'June 2026',

View file

@ -49,7 +49,7 @@ interface Props {
onLongPress?: () => void; onLongPress?: () => void;
} }
export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) { function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>(); useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions(); const { isSelecting, pendingDuplicate, openActions, pickDuplicateTarget } = useSceneActions();
@ -155,6 +155,12 @@ export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress
); );
} }
// 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({ const styles = StyleSheet.create({
tile: { flex: 1, marginBottom: 14 }, tile: { flex: 1, marginBottom: 14 },
thumbWrap: { thumbWrap: {
@ -254,5 +260,14 @@ export function sceneGridProps(cols: number) {
key: `scenegrid-${cols}`, key: `scenegrid-${cols}`,
numColumns: cols, numColumns: cols,
columnWrapperStyle: cols > 1 ? { gap: 10 } : undefined, 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,
}; };
} }

View file

@ -0,0 +1,148 @@
/**
* Mobile-side fpo.xxx (fpoxxx) resolver KVS (kt_player) phone-side.
*
* fpoxxx to KVS: flashvars `video_url` (czasem `function/0/...` zaszyfrowane, czasem już
* plain `.../get_file/...`) + `license_code`. get_file 302-redirectuje na CDN
* (videosN.fpo.xxx/remote_control.php) z `acctoken` BOUND DO IP fetchera + SESSION-bound
* (audit 2026-06-19: acctoken zawierał IP VPS, cross-IP = 403). Backend resolwuje z VPS
* telefon dostaje URL bound do IP VPS 403. Dawniej fallback = WebView, ale ad-heavy
* strona wstrzykuje VAST preroll WebView łapie REKLAMĘ zamiast wideo (bug-report
* f79beefb / cfa207c7).
*
* Fix: telefon sam pobiera stronę (phone IP + ta sama sesja co get_file), deobfuskuje
* KVS i follow 302 finalny CDN URL bound do telefonu gra direct, bez WebView/reklam.
* Port deobfuskacji z app/extractors/tubes/_kvs.py (real_url/_license_token, algo zgodny
* z yt-dlp KVS). 302-follow reużyty z getfileResolver (resolveGetFile).
*/
import { resolveGetFile } from './getfileResolver';
import type { StreamLink } from '../types';
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const _LICENSE_RE = /license_code\s*:\s*['"](\$[^'"]+)['"]/i;
const _URL_RE =
/(video(?:_alt)?_url\d*)\s*:\s*['"](function\/0\/[^'"]+|https?:\/\/[^'"]*?\/get_file\/[^'"]*)['"]/gi;
const _TEXT_RE = /(video(?:_alt)?_url\d*)_text\s*:\s*['"]([^'"]*)['"]/gi;
const _HASH_LEN = 32;
/** Port _license_token z _kvs.py (algo KVS / yt-dlp). */
function licenseToken(licenseCode: string): number[] {
const lc = licenseCode.replace(/\$/g, '');
const values = [...lc].map((c) => parseInt(c, 10));
let mod = lc.replace(/0/g, '1');
const center = Math.floor(mod.length / 2);
const left = parseInt(mod.slice(0, center + 1), 10);
const right = parseInt(mod.slice(center), 10);
mod = String(4 * Math.abs(left - right)).slice(0, center + 1);
const out: number[] = [];
for (let index = 0; index < mod.length; index++) {
const current = parseInt(mod[index], 10);
for (let offset = 0; offset < 4; offset++) {
out.push(((values[index + offset] ?? 0) + current) % 10);
}
}
return out;
}
/** Port real_url z _kvs.py permutuje pierwsze 32 znaki hash-segmentu. Plain get_file
* (bez `function/0/`) zwraca bez zmian. */
function realUrl(videoUrl: string, licenseCode: string): string {
const PREFIX = 'function/0/';
if (!videoUrl.startsWith(PREFIX)) return videoUrl;
let u: URL;
try {
u = new URL(videoUrl.slice(PREFIX.length));
} catch {
return videoUrl;
}
const lt = licenseToken(licenseCode);
if (lt.length < _HASH_LEN) return videoUrl;
const parts = u.pathname.split('/');
if (parts.length <= 3) return videoUrl;
const seg = parts[3];
const h = seg.slice(0, _HASH_LEN);
if (h.length < _HASH_LEN) return videoUrl;
const idx = Array.from({ length: _HASH_LEN }, (_, i) => i);
let acc = 0;
for (let src = _HASH_LEN - 1; src >= 0; src--) {
acc += lt[src];
const dest = (src + acc) % _HASH_LEN;
[idx[src], idx[dest]] = [idx[dest], idx[src]];
}
let permuted = '';
for (let i = 0; i < _HASH_LEN; i++) permuted += h[idx[i]];
parts[3] = permuted + seg.slice(_HASH_LEN);
u.pathname = parts.join('/');
return u.toString();
}
function qualityRank(label: string | null | undefined): number {
if (!label) return -1;
const m = /(\d{3,4})\s*p/i.exec(label);
return m ? parseInt(m[1], 10) : -1;
}
/**
* Pobiera fpo.xxx stronę NA TELEFONIE deobfuskuje KVS follow get_file 302 mp4
* StreamLink-i (phone-IP-bound, grają direct). Zwraca [] gdy brak (caller spada na
* backend/WebView). Mirror resolve_kvs z _kvs.py.
*/
export async function resolveFpoxxxPage(pageUrl: string): Promise<StreamLink[]> {
let html: string;
try {
const r = await fetch(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
if (!r.ok) return [];
html = await r.text();
} catch {
return [];
}
const licm = _LICENSE_RE.exec(html);
if (!licm) return [];
const licenseCode = licm[1];
// jakości po `<var>_text`
const qualityByVar: Record<string, string> = {};
let tm: RegExpExecArray | null;
_TEXT_RE.lastIndex = 0;
while ((tm = _TEXT_RE.exec(html)) !== null) {
qualityByVar[tm[1].toLowerCase()] = tm[2].trim();
}
let origin: string;
try {
origin = new URL(pageUrl).origin;
} catch {
origin = 'https://www.fpo.xxx';
}
const referer = origin + '/';
const seen = new Set<string>();
const links: StreamLink[] = [];
let fails = 0;
let um: RegExpExecArray | null;
_URL_RE.lastIndex = 0;
while ((um = _URL_RE.exec(html)) !== null) {
const varName = um[1].toLowerCase();
const decoded = realUrl(um[2], licenseCode);
if (seen.has(decoded)) continue;
seen.add(decoded);
const final = await resolveGetFile(decoded, referer);
if (!final) {
fails += 1;
if (fails >= 2 && links.length === 0) break; // martwa scena — nie czekaj dalej
continue;
}
links.push({
stream_url: final,
direct_url: final,
headers: { Referer: referer, 'User-Agent': UA },
quality: qualityByVar[varName] || null,
type: 'mp4',
});
}
links.sort((a, b) => qualityRank(b.quality) - qualityRank(a.quality));
return links;
}

View file

@ -24,6 +24,7 @@ import { isGetFileUrl, resolveGetFilePage } from '../lib/getfileResolver';
import { resolvePornxpPage } from '../lib/pornxpResolver'; import { resolvePornxpPage } from '../lib/pornxpResolver';
import { resolveSxyprnPage } from '../lib/sxyprnResolver'; import { resolveSxyprnPage } from '../lib/sxyprnResolver';
import { resolveEpornerPage } from '../lib/epornerResolver'; import { resolveEpornerPage } from '../lib/epornerResolver';
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { PlaybackSource, SceneOut, StreamLink } from '../types'; import type { PlaybackSource, SceneOut, StreamLink } from '../types';
@ -603,6 +604,8 @@ function PlaybackButton({
? resolveSxyprnPage ? resolveSxyprnPage
: source.origin === 'tube:epornercom' : source.origin === 'tube:epornercom'
? resolveEpornerPage ? resolveEpornerPage
: source.origin === 'tube:fpoxxx'
? resolveFpoxxxPage
: null; : null;
if (phoneResolver) { if (phoneResolver) {
setResolving(true); setResolving(true);

View file

@ -208,9 +208,8 @@ export function ScenesScreen() {
{...sceneGridProps(gridColumns)} {...sceneGridProps(gridColumns)}
data={items} data={items}
keyExtractor={(s) => s.id} keyExtractor={(s) => s.id}
// Android removeClippedSubviews=true (default) blankuje miniaturki po scrollu — // removeClippedSubviews + okno renderowania (windowSize itd.) idą z sceneGridProps
// expo-image nie re-renderuje odpiętych subview. Bug-report "znikają miniaturki". // — patrz SceneTile. Perf bug-report 5b7ca1e1 (jank/obciążenie przy długim scrollu).
removeClippedSubviews={false}
renderItem={({ item }) => <SceneTile scene={item} />} renderItem={({ item }) => <SceneTile scene={item} />}
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null} ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null}
refreshing={isRefetching} refreshing={isRefetching}