diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index ab0b77c..febd781 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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', diff --git a/mobile/src/components/SceneTile.tsx b/mobile/src/components/SceneTile.tsx index 13b004a..1068cc9 100644 --- a/mobile/src/components/SceneTile.tsx +++ b/mobile/src/components/SceneTile.tsx @@ -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>(); 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, }; } diff --git a/mobile/src/lib/fpoxxxResolver.ts b/mobile/src/lib/fpoxxxResolver.ts new file mode 100644 index 0000000..285477e --- /dev/null +++ b/mobile/src/lib/fpoxxxResolver.ts @@ -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 { + 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 `_text` + const qualityByVar: Record = {}; + 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(); + 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; +} diff --git a/mobile/src/screens/SceneDetailScreen.tsx b/mobile/src/screens/SceneDetailScreen.tsx index 960b8fe..a9b9a47 100644 --- a/mobile/src/screens/SceneDetailScreen.tsx +++ b/mobile/src/screens/SceneDetailScreen.tsx @@ -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 { diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index 57a2d5b..fba528f 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -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 }) => } ListHeaderComponent={!debouncedQ && activeCount === 0 ? : null} refreshing={isRefetching}