goon/mobile/src/screens/SceneDetailScreen.tsx
goon-foss 2a0e46e17d fix(mobile): re-resolve IP-bound tubes on playback error (sxyprn/eporner/fpoxxx)
sxyprn's video token is bound to the IP that fetched the post page; on mobile the
phone resolver works ~74% but ~26% fail when the egress IP shifts (CGNAT / network
switch) or the token goes stale → native player hung on a dead URL (18 reports, 26%
error rate in telemetry). Now on an initial-load error for these phone-resolved
tubes, the player re-fetches the page fresh (new token bound to the current IP) and
swaps the source before falling through to the proxy/WebView chain. Zero VPS
bandwidth. Gated by resolvePageUrl so other tubes are completely unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:11:21 +02:00

968 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { RouteProp } from '@react-navigation/native';
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ApiError } from '../api';
import * as IntentLauncher from 'expo-intent-launcher';
import React from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
useWindowDimensions,
View,
} from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useClient } from '../ClientContext';
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';
import { PlaybackQualityModal } from './PlaybackQualityModal';
export function SceneDetailScreen() {
const client = useClient();
const queryClient = useQueryClient();
const route = useRoute<RouteProp<RootStackParamList, 'SceneDetail'>>();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const { id } = route.params;
const { data, isLoading, error } = useQuery({
queryKey: ['scene', id],
queryFn: () => client.getScene(id),
});
const isFav = data?.is_favorite ?? false;
const favMutation = useMutation({
mutationFn: () => (isFav ? client.removeSceneFavorite(id) : client.addSceneFavorite(id)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scene', id] });
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
},
});
// Auto-enrich tags z tube'a gdy scena ma 0 tagów + jest tube playback source.
// Mainstream tubes (xhamster/porntrex/eporner/...) mają categories per scene które
// yt-dlp generic nie wyciąga. Strzelamy raz przy otwarciu sceny — endpoint jest
// idempotentny, kolejne otwarcia tej samej sceny już nic nie robią (gdy są tagi).
const enrichMutation = useMutation({
mutationFn: () => client.enrichSceneTags(id),
onSuccess: (out) => {
if (out.added > 0) {
queryClient.invalidateQueries({ queryKey: ['scene', id] });
}
},
});
React.useEffect(() => {
if (!data) return;
if (data.tags.length > 0) return;
const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:'));
if (!hasTubeSource) return;
if (enrichMutation.isPending || enrichMutation.isSuccess) return;
enrichMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.id, data?.tags.length]);
// Auto-enrich duration: youporn/xvideos/xnxx/etc udostępniają og:video:duration
// lub LD-JSON ISO 8601 — direct scrapery (search-only) ich nie pobierają, więc
// sceny przychodzą z duration_sec=null. Tu jeden fetch → backfill. Bez tego
// dedup capuje composite na 0.85 (bez strong signal), co ratuje od false-merge,
// ale też blokuje legit auto-merges między tymi samymi scenami z różnych tube'ów.
const enrichDurationMutation = useMutation({
mutationFn: () => client.enrichSceneDuration(id),
onSuccess: (out) => {
if (out.duration_sec) {
queryClient.invalidateQueries({ queryKey: ['scene', id] });
}
},
});
React.useEffect(() => {
if (!data) return;
if (data.duration_sec) return;
const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:'));
if (!hasTubeSource) return;
if (enrichDurationMutation.isPending || enrichDurationMutation.isSuccess) return;
enrichDurationMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.id, data?.duration_sec]);
// Auto-enrich thumbnail: dla scen ingestowanych z search-only scrapery
// (hdporn92, sxyland, sxyprn itp.) thumbnail_url=None w PlaybackSource.
// Detail page wystawia og:image — jeden fetch tu napełnia wszystkie źródła
// dla tej sceny. SceneDetail = naturalny moment (user już patrzy na hero).
const enrichThumbMutation = useMutation({
mutationFn: () => client.enrichSceneThumbnail(id),
onSuccess: (out) => {
if (out.thumbnail_url) {
queryClient.invalidateQueries({ queryKey: ['scene', id] });
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
}
},
});
React.useEffect(() => {
if (!data) return;
const hasThumb = data.playback_sources.some((s) => s.thumbnail_url);
if (hasThumb) return;
const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:'));
if (!hasTubeSource) return;
if (enrichThumbMutation.isPending || enrichThumbMutation.isSuccess) return;
enrichThumbMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.id]);
// Ręczny refresh miniatury (force) — zgłoszenie d3376a71: zepsuta/stała miniaturka
// (np. rotting sxyprn/trafficdeposit). Nadpisuje istniejącą świeżą ze strony tube'a.
const refreshThumbMutation = useMutation({
mutationFn: () => client.enrichSceneThumbnail(id, true),
onSuccess: (out) => {
queryClient.invalidateQueries({ queryKey: ['scene', id] });
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
Alert.alert(
'Thumbnail',
out.thumbnail_url ? 'Refreshed from the source page.' : 'Could not fetch a fresh thumbnail.',
);
},
onError: (e) => Alert.alert('Thumbnail', e instanceof Error ? e.message : String(e)),
});
// Stan ładowania hero-miniatury: 'ok' (załadowała się — domyślnie, brak migotania
// przycisku), 'none' (brak URL), 'broken' (onError). Steruje widocznością przycisku
// Refresh. Reset przy zmianie sceny.
const [thumbState, setThumbState] = React.useState<'none' | 'ok' | 'broken'>('ok');
React.useEffect(() => {
setThumbState('ok');
}, [id]);
// Auto-enrich tags: search-only scrapery nie pobierają tagów z detail page.
// Pornhat ma `js-ajax-tag` data-setup JSON; xhamster/xvideos/youporn/inne mają
// dedicated tag_extract patterny. SceneDetail wywołuje 1 fetch → upsert do DB.
const enrichTagsMutation = useMutation({
mutationFn: () => client.enrichSceneTags(id),
onSuccess: (out) => {
if (out.added > 0) queryClient.invalidateQueries({ queryKey: ['scene', id] });
},
});
React.useEffect(() => {
if (!data) return;
if ((data.tags?.length ?? 0) > 0) return;
const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:'));
if (!hasTubeSource) return;
if (enrichTagsMutation.isPending || enrichTagsMutation.isSuccess) return;
enrichTagsMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.id]);
// Auto-enrich studio: pornhat ma `js-ajax-dvd` data-setup (DDF/Adult Time/...).
// Inne tube'y bez handlera — endpoint zwraca null bez modyfikacji.
const enrichStudioMutation = useMutation({
mutationFn: () => client.enrichSceneStudio(id),
onSuccess: (out) => {
if (out.studio_id) queryClient.invalidateQueries({ queryKey: ['scene', id] });
},
});
React.useEffect(() => {
if (!data) return;
if (data.studio) return;
const hasPornhat = data.playback_sources.some((s) => s.origin === 'tube:pornhatcom');
if (!hasPornhat) return;
if (enrichStudioMutation.isPending || enrichStudioMutation.isSuccess) return;
enrichStudioMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data?.id]);
// Long-press tag → confirm → DELETE relację scene_tag (tag zostaje w słowniku,
// ale scena go nie ma). User-curated odśmiecanie błędnych tagów.
const removeTagMutation = useMutation({
mutationFn: (tagId: string) => client.removeTagFromScene(id, tagId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scene', id] }),
onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)),
});
// Long-press performer pill → DELETE relację scene_performer. Analogicznie
// do tagów: false-match fuzzy (np. "anna bella" trafiona pod "Bad Bella" przez
// alias "Bella") usuwany ręcznie z konkretnej sceny — performerka zostaje w
// słowniku, tylko ta scena ją odpina (zgłoszenia #03e8044a, #12b8c122).
const removePerformerMutation = useMutation({
mutationFn: (perfId: string) => client.removePerformerFromScene(id, perfId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scene', id] }),
onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)),
});
const onPerformerLongPress = (perfId: string, perfName: string) => {
Alert.alert(
'Remove performer',
`Remove "${perfName}" from this scene?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removePerformerMutation.mutate(perfId),
},
],
);
};
const onTagLongPress = (tagId: string, tagName: string) => {
Alert.alert(
'Remove tag',
`Remove tag "${tagName}" from this scene?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => removeTagMutation.mutate(tagId),
},
],
);
};
React.useLayoutEffect(() => {
nav.setOptions({
headerRight: () => (
<Pressable onPress={() => favMutation.mutate()} hitSlop={12} disabled={favMutation.isPending}>
<Text style={{ color: isFav ? theme.accent : theme.muted, fontSize: 22 }}>
{isFav ? '★' : '☆'}
</Text>
</Pressable>
),
});
}, [nav, isFav, favMutation]);
if (isLoading) return <ActivityIndicator style={{ flex: 1, backgroundColor: theme.bg }} color={theme.fg} />;
if (error instanceof Error) return <Text style={styles.error}>{error.message}</Text>;
if (!data) return null;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.scrollContent}>
<Hero data={data} onThumbState={setThumbState} />
<View style={styles.body}>
{/* Refresh pokazujemy gdy wyświetlana miniatura faktycznie się NIE ładuje
(brak URL lub obraz padł — onError), nie tylko po obecności URL. Łączy
feedback 26c114ed (nie na każdej scenie z dobrą miniaturą) i ef0c6a5a
(thumbnail_url bywa obecny, ale obraz 404 → user widzi puste pole). */}
{data.playback_sources.some((s) => s.origin?.startsWith('tube:')) &&
thumbState !== 'ok' ? (
<Pressable
style={styles.refreshThumbBtn}
onPress={() => refreshThumbMutation.mutate()}
disabled={refreshThumbMutation.isPending}
>
<Text style={styles.refreshThumbText}>
{refreshThumbMutation.isPending ? 'Refreshing thumbnail…' : '↻ Refresh thumbnail'}
</Text>
</Pressable>
) : null}
{(data.code || data.director) && (
<View style={styles.metaRow}>
{data.code ? <Text style={styles.metaDim}>code · {data.code}</Text> : null}
{data.director ? <Text style={styles.metaDim}>dir · {data.director}</Text> : null}
</View>
)}
{data.performers.length > 0 && (
<Section title="Performers">
<View style={styles.pillRow}>
{data.performers.map((p) => (
<Pressable
key={p.id}
style={styles.pillPrimary}
onPress={() =>
nav.push('PerformerScenes', { id: p.id, name: p.canonical_name })
}
onLongPress={() => onPerformerLongPress(p.id, p.canonical_name)}
delayLongPress={400}
>
<Text style={styles.pillPrimaryText}>
{p.canonical_name}
{p.as_alias ? ` (as ${p.as_alias})` : ''}
</Text>
</Pressable>
))}
</View>
<Text style={styles.tagHint}>long-press to remove from this scene</Text>
</Section>
)}
{data.studio && (
<Section title="Studio">
<View style={styles.pillRow}>
<Pressable
style={styles.pillPrimary}
onPress={() =>
nav.push('StudioScenes', {
id: data.studio!.slug,
studioId: data.studio!.id,
name: data.studio!.name,
})
}
>
<Text style={styles.pillPrimaryText}>
{data.studio.name}
{data.studio.network ? ` · ${data.studio.network}` : ''}
</Text>
</Pressable>
</View>
</Section>
)}
{data.tags.length > 0 && (
<Section title="Tags">
<View style={styles.pillRow}>
{data.tags.map((t) => (
<Pressable
key={t.id}
style={styles.pillSecondary}
onPress={() => nav.push('TagScenes', { slug: t.slug, name: t.name })}
onLongPress={() => onTagLongPress(t.id, t.name)}
delayLongPress={500}
>
<Text style={styles.pillSecondaryText}>{t.name}</Text>
</Pressable>
))}
</View>
<Text style={styles.tagHint}>long-press to remove from this scene</Text>
</Section>
)}
{data.external_refs.length > 0 && (
<Section title="Sources">
<View style={styles.pillRow}>
{(() => {
// Sceny popularne mogą mieć dziesiątki refów na ten sam tube (różne URL,
// np. /search/.../ + /tag/... + /performer/... — wszystkie dla tej samej sceny).
// Deduplikujemy po source name, pokazujemy count jeśli > 1.
const counts = new Map<string, number>();
for (const r of data.external_refs) {
counts.set(r.source, (counts.get(r.source) ?? 0) + 1);
}
return Array.from(counts.entries()).map(([source, count]) => (
<View key={source} style={styles.pillSource}>
<Text style={styles.pillSourceText}>
{source}{count > 1 ? ` ×${count}` : ''}
</Text>
</View>
));
})()}
</View>
</Section>
)}
{data.playback_sources.length > 0 && (
<Section title="Watch">
{data.playback_sources.map((p) => (
<PlaybackButton
key={p.id}
sceneId={data.id}
sceneDurationSec={data.duration_sec ?? null}
source={p}
/>
))}
<Text style={styles.tagHint}>long-press: open in browser / mark as broken</Text>
</Section>
)}
{data.description ? (
<Section title="Description">
<Text style={styles.description}>{data.description}</Text>
</Section>
) : null}
</View>
</ScrollView>
);
}
function Hero({
data,
onThumbState,
}: {
data: SceneOut;
onThumbState?: (s: 'none' | 'ok' | 'broken') => void;
}) {
const { width } = useWindowDimensions();
const heroHeight = Math.round(width * (9 / 16));
const animatedUrl = data.playback_sources.find((s) => s.animated_thumbnail_url)
?.animated_thumbnail_url;
const staticUrl = data.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url;
const url = animatedUrl ?? staticUrl;
React.useEffect(() => {
if (!url) onThumbState?.('none');
}, [url, onThumbState]);
const minutes = data.duration_sec ? Math.floor(data.duration_sec / 60) : null;
const meta = [
data.release_date ?? null,
data.studio?.name ?? null,
minutes !== null ? `${minutes} min` : null,
]
.filter(Boolean)
.join(' · ');
if (!url) {
return (
<View style={[styles.heroFallback, { minHeight: heroHeight * 0.55 }]}>
<Text style={styles.heroTitle} numberOfLines={3}>
{data.title}
</Text>
{meta ? <Text style={styles.heroMeta}>{meta}</Text> : null}
</View>
);
}
return (
<View style={[styles.hero, { height: heroHeight }]}>
<Image
source={{ uri: url }}
style={StyleSheet.absoluteFill}
contentFit="cover"
transition={200}
onLoad={() => onThumbState?.('ok')}
onError={() => onThumbState?.('broken')}
/>
<LinearGradient
colors={['transparent', 'rgba(8,9,15,0.55)', theme.bg]}
locations={[0, 0.55, 1]}
style={StyleSheet.absoluteFill}
pointerEvents="none"
/>
<View style={styles.heroOverlay}>
<Text style={styles.heroTitle} numberOfLines={3}>
{data.title}
</Text>
{meta ? <Text style={styles.heroMeta}>{meta}</Text> : null}
</View>
</View>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function guessVideoMime(url: string): string {
const path = url.split('?')[0].toLowerCase();
if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
if (path.endsWith('.mpd')) return 'application/dash+xml';
if (path.endsWith('.webm')) return 'video/webm';
if (path.endsWith('.mkv')) return 'video/x-matroska';
if (path.endsWith('.mp4')) return 'video/mp4';
return 'video/*';
}
function PlaybackButton({
sceneId,
sceneDurationSec,
source,
}: {
sceneId: string;
sceneDurationSec: number | null;
source: PlaybackSource;
}) {
const client = useClient();
const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const [resolving, setResolving] = React.useState(false);
const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null);
// Tube origin format: 'tube:hqpornercom' (nowy) lub 'pornapp:hqpornercom' (legacy
// sprzed migracji 0011) — backend rozumie oba prefixy.
const isTube = source.origin.startsWith('tube:') || source.origin.startsWith('pornapp:');
// Watch tracking: każde uruchomienie playera/browsera oznaczamy jako "started"
// (position_sec=0 jeśli pierwszy raz; nie ruszamy jeśli już była progres).
// Continue watching rail i `last_played_at` na home są zasilane stąd.
const markStarted = React.useCallback(() => {
client
.upsertProgress(sceneId, {
position_sec: 0,
duration_sec: sceneDurationSec ?? undefined,
})
.then(() => {
queryClient.invalidateQueries({ queryKey: ['watch-recent'] });
})
.catch(() => {
// best-effort, nie blokujemy odtwarzania
});
}, [client, sceneId, sceneDurationSec, queryClient]);
// origin format: 'tube:hqpornercom' → display 'hqpornercom'
const label = source.origin.includes(':') ? source.origin.split(':').slice(1).join(':') : source.origin;
const openUrl = async (url: string) => {
const ok = await Linking.canOpenURL(url);
if (!ok) {
Alert.alert('Cannot open', url);
return;
}
await Linking.openURL(url);
};
// Inline player (expo-video / ExoPlayer) — odtwarza w tej samej apce, pasuje headery
// (Referer/UA) potrzebne dla CDN-ów które blokują requesty bez właściwego origin'u
// (hqporner/iceyfile/itd.).
// Refererem jest origin z PlaybackSource.page_url (np. "hqporner.com").
// `fallbackEmbedUrl` jest używany przez Player gdy native ExoPlayer dostanie błąd
// (typowe dla IP-bound CDN URL'i z luluvids/iceyfile).
const openAsVideo = async (
link: StreamLink,
fallbackEmbedUrl?: string,
) => {
let refererHost: string | undefined;
try {
refererHost = new URL(source.page_url).hostname;
} catch {
// ignore
}
// Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy URL (jeśli direct
// fails). Backend dostarcza oba w StreamLink. Headers tylko dla direct path.
let initialUrl = link.direct_url || link.stream_url!;
const isDirect = !!link.direct_url && initialUrl === link.direct_url;
// hdporn.gg/fullmovies.xxx: direct_url to `.../get_file/...` które 302-redirectuje
// na fpvcdn z IP fetchera. ExoPlayer wywala się na tym cross-domain redirekcie →
// fallback proxy → „mignięcie". Resolvujemy redirect TU (na telefonie → fpvcdn z IP
// telefonu) i podajemy ExoPlayerowi finalny URL — bez błędu/migotania/proxy. Fail =
// gramy oryginał (obecne zachowanie z fallbackiem). ~100-300ms (get_file 302 szybki).
// hdporn.gg/fullmovies.xxx: backendowy get_file jest zbindowany do IP VPS (page-loader)
// → telefon dostaje 410 → fallback proxy (mignięcie). Re-fetchujemy STRONĘ na telefonie
// (phone-bound get_file) → fpvcdn z IP telefonu → ExoPlayer gra direct (bez proxy/migotania).
if (isDirect && isGetFileUrl(initialUrl)) {
const ref = link.headers?.Referer || (refererHost ? `https://${refererHost}/` : undefined);
const resolved = await resolveGetFilePage(source.page_url, link.quality, ref);
if (resolved) initialUrl = resolved;
}
// IP-bound tuby resolwowane phone-side: przekaż page_url, by Player mógł
// re-resolve'ować świeży token gdy native padnie na initial-load (zmiana IP / TTL).
const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx'];
nav.navigate('Player', {
url: initialUrl,
sceneId,
origin: source.origin,
resolvePageUrl: PHONE_RESOLVE_ORIGINS.includes(source.origin) ? source.page_url : undefined,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,
title: source.origin,
headers: isDirect && link.headers ? link.headers : undefined,
fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined,
fallbackEmbedUrl,
});
};
const onPress = async () => {
// Non-tube origin: brak własnego extractora (np. paradisehill bez resolve), otwieramy
// page/embed/stream w przeglądarce.
if (!isTube) {
try {
markStarted();
await openUrl(source.stream_url || source.embed_url || source.page_url);
} catch (e) {
Alert.alert('Failed to open', e instanceof Error ? e.message : String(e));
}
return;
}
// pornxp.ph: CDN token IP-bound (backend 403 cross-IP) → backend oddaje WebView
// fallback który czarno-ekranił (bug-report 2026-06-07, fd06cd86). Telefon sam
// pobiera stronę (phone-IP-bound mp4) → natywne multi-quality, zero WebView/reklam.
if (source.origin === 'tube:pornxpph') {
setResolving(true);
try {
const links = await resolvePornxpPage(source.page_url);
if (links.length > 0) {
markStarted();
if (links.length === 1) await openAsVideo(links[0], source.page_url);
else setQualityLinks(links);
return;
}
// pusto → spadnij na backend resolve (WebView) poniżej
} catch {
// ignore → backend fallback
} finally {
setResolving(false);
}
}
// sxyprn / eporner: CDN token IP-bound do tego KTO POBRAŁ STRONĘ (audit 2026-06-11).
// Backend resolvuje z VPS → telefon dostaje URL bound do IP VPS → direct daje 403 /
// 10B placeholder → fallback na proxy → CAŁE wideo przez Hetzner. Telefon sam pobiera
// stronę (phone IP) → token bound do telefonu → gra direct, zero VPS. [] → backend niżej.
const phoneResolver =
source.origin === 'tube:sxyprncom'
? resolveSxyprnPage
: source.origin === 'tube:epornercom'
? resolveEpornerPage
: source.origin === 'tube:fpoxxx'
? resolveFpoxxxPage
: null;
if (phoneResolver) {
setResolving(true);
try {
const links = await phoneResolver(source.page_url);
if (links.length > 0) {
markStarted();
if (links.length === 1) await openAsVideo(links[0], source.page_url);
else setQualityLinks(links);
return;
}
// pusto → spadnij na backend resolve poniżej (HosterDead → mark dead dla
// skasowanego sxyprn posta; backend i tak ma proxy fallback).
} catch {
// ignore → backend fallback
} finally {
setResolving(false);
}
}
// Tube origin: backend resolve → lista linków: część direct video (stream_url),
// część hoster embed (embed_url, np. StreamWish/doodporn HTML page).
// - direct → MX Player (intent video/*)
// - embed-only → browser (HTML page nie odtworzy się w MX, MX wywaliłby błąd)
setResolving(true);
try {
const res = await client.resolvePlayback(sceneId, source.id);
const all = res.links || [];
// Dedup po URL — porn-app czasem dubluje ten sam link z różnym quality string.
const seen = new Set<string>();
const unique = all.filter((l) => {
const key = l.stream_url || l.embed_url || '';
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
const directLinks = unique.filter((l) => !!l.stream_url);
const embedLinks = unique.filter((l) => !l.stream_url && !!l.embed_url);
if (directLinks.length === 0 && embedLinks.length === 0) {
Alert.alert('No stream', 'porn-app did not return any stream URL — falling back to the page.');
await openUrl(source.page_url);
return;
}
// Brak direct video → otwórz embed w naszym WebView playerze. WebView (Chrome
// engine) wpuszczany jest przez WAF/JA3-fingerprint hostów (xtremestream,
// playmogo, streamtape) które blokują direct fetch z native playera.
// Player JS hostera może też ujawnić finalny m3u8 — auto-extract w PlayerScreen
// pokaże banner "Otwórz w native playerze".
if (directLinks.length === 0) {
const target = res.best?.embed_url || embedLinks[0].embed_url!;
let refererHost: string | undefined;
try {
refererHost = new URL(source.page_url).hostname;
} catch {
// ignore
}
markStarted();
nav.navigate('Player', {
url: target,
sceneId,
origin: source.origin,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,
title: source.origin,
mode: 'webview',
});
return;
}
// Fallback embed do Playera — gdy ExoPlayer dostanie 403 (IP-bound CDN), apka
// przełączy się na WebView z tym embed URL'em. Bierzemy pierwszy embed jaki
// jest, niezależnie od kolejności w `links`.
const fallbackEmbedUrl = embedLinks[0]?.embed_url || res.best?.embed_url || undefined;
if (directLinks.length === 1) {
markStarted();
await openAsVideo(directLinks[0], fallbackEmbedUrl);
return;
}
setQualityLinks(directLinks);
} catch (e) {
// 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception
// lub tube page 404/410). Refresh sceny żeby ten button zniknął z listy.
if (e instanceof ApiError && e.status === 410) {
Alert.alert(
'Dead link',
'The tube removed this video. We marked the source, you won\'t see it again.',
);
queryClient.invalidateQueries({ queryKey: ['scene', sceneId] });
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
return;
}
// 503 = transient extraction failure (hoster chwilowo niedostępny, network
// glitch, ad-iframe race condition). NIE oznaczamy źródła jako dead —
// następne kliknięcie pewnie zadziała.
if (e instanceof ApiError && e.status === 503) {
Alert.alert(
'Try again',
'Temporary problem with the host. Tap Play again in a moment.',
);
return;
}
Alert.alert(
'Resolve failed',
(e instanceof Error ? e.message : String(e)) + '\n\nFallback: opening source page.',
);
try {
await openUrl(source.page_url);
} catch {
// pomijamy — alert już był
}
} finally {
setResolving(false);
}
};
const onSelectQuality = async (link: StreamLink) => {
setQualityLinks(null);
try {
markStarted();
if (link.stream_url) {
// Wybór z dropdownu jakości — fallback na inny embed gdyby ten konkretny
// direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc
// szukamy embed'a w `links` na resolve response wcześniej; tu już go nie
// mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct).
await openAsVideo(link);
} else if (link.embed_url) {
let refererHost: string | undefined;
try {
refererHost = new URL(source.page_url).hostname;
} catch {
// ignore
}
nav.navigate('Player', {
url: link.embed_url,
sceneId,
origin: source.origin,
playbackId: source.id,
durationSec: sceneDurationSec,
refererHost,
title: source.origin,
mode: 'webview',
});
}
} catch (e) {
Alert.alert('Failed to open', e instanceof Error ? e.message : String(e));
}
};
const action = isTube ? (resolving ? 'resolving…' : 'pick quality & play') : 'open in browser';
// Long-press → action sheet z 3 opcjami. Bug-reports 2026-05-12/2026-05-20:
// (1) user chce móc usunąć złe linki (xhamster wrong-video/eporner wrong-scene),
// (2) user chce diagnostyki — otworzyć page_url w browser (zobaczyć HTML tube'a
// bez WebView playera), żeby rozsądnie ocenić czy hoster faktycznie broken.
const onLongPress = () => {
Alert.alert(
label,
'What do you want to do with this link?',
[
{
text: 'Open in browser (diagnostics)',
onPress: async () => {
try {
const url = source.page_url || source.embed_url || source.stream_url;
if (url) {
await Linking.openURL(url);
} else {
Alert.alert('No URL', 'This playback has no page_url to open.');
}
} catch (e) {
Alert.alert('Could not open', e instanceof Error ? e.message : String(e));
}
},
},
{
text: 'Mark as invalid',
style: 'destructive',
onPress: async () => {
try {
await client.markPlaybackDead(sceneId, source.id);
queryClient.invalidateQueries({ queryKey: ['scene', sceneId] });
queryClient.invalidateQueries({ queryKey: ['scenes'], refetchType: 'none' });
} catch (e) {
Alert.alert('Failed', e instanceof Error ? e.message : String(e));
}
},
},
{ text: 'Cancel', style: 'cancel' },
],
);
};
return (
<>
<Pressable
style={({ pressed }) => [
styles.watchButton,
pressed && styles.watchButtonPressed,
]}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={500}
disabled={resolving}
>
<Text style={styles.watchLabel}>{label}</Text>
<Text style={styles.watchMeta}>
{source.quality ? `${source.quality} · ` : ''}
{source.duration_sec ? `${Math.floor(source.duration_sec / 60)}m · ` : ''}
{action}
</Text>
</Pressable>
<PlaybackQualityModal
visible={qualityLinks !== null}
links={qualityLinks ?? []}
onSelect={onSelectQuality}
onCancel={() => setQualityLinks(null)}
/>
</>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.bg },
scrollContent: { paddingBottom: 32 },
hero: {
width: '100%',
backgroundColor: theme.card,
overflow: 'hidden',
},
heroOverlay: {
position: 'absolute',
left: 16,
right: 16,
bottom: 12,
},
heroFallback: {
paddingHorizontal: 16,
paddingTop: 24,
paddingBottom: 16,
backgroundColor: theme.bgElevated,
justifyContent: 'flex-end',
},
heroTitle: {
color: theme.fg,
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.3,
textShadowColor: 'rgba(0,0,0,0.6)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 4,
},
heroMeta: {
color: theme.muted,
fontSize: 13,
marginTop: 6,
fontWeight: '500',
},
body: { paddingHorizontal: 16, paddingTop: 8 },
metaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 8 },
metaDim: { color: theme.mutedDim, fontSize: 12, fontWeight: '500' },
section: { marginTop: 20 },
sectionTitle: {
color: theme.mutedDim,
fontSize: 11,
textTransform: 'uppercase',
marginBottom: 10,
letterSpacing: 1.2,
fontWeight: '700',
},
pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
pillPrimary: {
borderColor: theme.accent,
borderWidth: 1,
backgroundColor: `${theme.accent}1A`,
borderRadius: 16,
paddingHorizontal: 12,
paddingVertical: 6,
},
pillPrimaryText: {
color: theme.accentGlow,
fontSize: 13,
fontWeight: '600',
},
pillSecondary: {
borderColor: theme.border,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 10,
paddingVertical: 5,
},
pillSecondaryText: {
color: theme.muted,
fontSize: 13,
},
tagHint: { color: theme.mutedDim, fontSize: 11, marginTop: 6 },
refreshThumbBtn: {
alignSelf: 'flex-start',
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.card,
marginBottom: 12,
},
refreshThumbText: { color: theme.muted, fontSize: 13, fontWeight: '600' },
pillSource: {
borderColor: theme.accent,
borderWidth: 1,
borderRadius: 14,
paddingHorizontal: 10,
paddingVertical: 4,
},
pillSourceText: {
color: theme.accent,
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.6,
},
description: { color: theme.fg, fontSize: 14, lineHeight: 21 },
error: { color: theme.bad, padding: 16 },
watchButton: {
backgroundColor: theme.bgElevated,
borderColor: theme.accent,
borderWidth: 1.5,
borderRadius: 12,
padding: 14,
marginBottom: 10,
shadowColor: theme.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.25,
shadowRadius: 8,
elevation: 3,
},
watchButtonPressed: {
backgroundColor: `${theme.accent}26`,
borderColor: theme.accentGlow,
},
watchLabel: { color: theme.accentGlow, fontWeight: '700', fontSize: 15 },
watchMeta: { color: theme.muted, fontSize: 12, marginTop: 4 },
});