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:
parent
e4cb94bc59
commit
567a8fb3b5
5 changed files with 178 additions and 5 deletions
|
|
@ -16,6 +16,14 @@ export type 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',
|
||||
date: 'June 2026',
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ interface Props {
|
|||
onLongPress?: () => void;
|
||||
}
|
||||
|
||||
export function SceneTile({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
|
||||
function SceneTileBase({ scene, secondLine = 'studio', seenSince, onLongPress }: Props) {
|
||||
const navigation =
|
||||
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
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({
|
||||
tile: { flex: 1, marginBottom: 14 },
|
||||
thumbWrap: {
|
||||
|
|
@ -254,5 +260,14 @@ export function sceneGridProps(cols: number) {
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
148
mobile/src/lib/fpoxxxResolver.ts
Normal file
148
mobile/src/lib/fpoxxxResolver.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import { isGetFileUrl, resolveGetFilePage } from '../lib/getfileResolver';
|
|||
import { resolvePornxpPage } from '../lib/pornxpResolver';
|
||||
import { resolveSxyprnPage } from '../lib/sxyprnResolver';
|
||||
import { resolveEpornerPage } from '../lib/epornerResolver';
|
||||
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import { theme } from '../theme';
|
||||
import type { PlaybackSource, SceneOut, StreamLink } from '../types';
|
||||
|
|
@ -603,7 +604,9 @@ function PlaybackButton({
|
|||
? resolveSxyprnPage
|
||||
: source.origin === 'tube:epornercom'
|
||||
? resolveEpornerPage
|
||||
: null;
|
||||
: source.origin === 'tube:fpoxxx'
|
||||
? resolveFpoxxxPage
|
||||
: null;
|
||||
if (phoneResolver) {
|
||||
setResolving(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -208,9 +208,8 @@ export function ScenesScreen() {
|
|||
{...sceneGridProps(gridColumns)}
|
||||
data={items}
|
||||
keyExtractor={(s) => s.id}
|
||||
// Android removeClippedSubviews=true (default) blankuje miniaturki po scrollu —
|
||||
// expo-image nie re-renderuje odpiętych subview. Bug-report "znikają miniaturki".
|
||||
removeClippedSubviews={false}
|
||||
// removeClippedSubviews + okno renderowania (windowSize itd.) idą z sceneGridProps
|
||||
// — patrz SceneTile. Perf bug-report 5b7ca1e1 (jank/obciążenie przy długim scrollu).
|
||||
renderItem={({ item }) => <SceneTile scene={item} />}
|
||||
ListHeaderComponent={!debouncedQ && activeCount === 0 ? <ContinueWatchingRail /> : null}
|
||||
refreshing={isRefetching}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue