goon/mobile/src/screens/SceneDetailScreen.tsx
goon-foss e215b48255 fix(watchporn): re-resolve stale CDN URL in-player, drop WebView page fallback
The zload CDN URL (remote_control.php) is a valid faststart progressive mp4 that
ExoPlayer plays fine when fresh: 206, no IP/referer binding, ~2h token TTL,
verified from a residential IP. Real-device failures (player_error/gone
telemetry) come from the static URL going stale: a rotated or dead CDN node, or
an expired token.

Instead of falling back to the tube page in a WebView (removed, it papered over
the real issue), PlayerScreen now re-resolves the scene fresh via the backend on
initial-load error for backend-native KVS tubes (watchporn). That yields a live
node plus a fresh token and stays on the native direct stream (0 VPS bandwidth).
For these origins a CDN 'gone' (404) means stale URL, not deleted, so we
re-resolve on gone too; a genuinely deleted post raises HosterDead (410) and
falls through to the normal fallback chain.

Also: add playback_events.error_detail (raw ExoPlayer message) to pin down the
exact failure of tubes we cannot reproduce on the emulator. Keep FLAG_SECURE on
release builds only (!__DEV__) so debug builds stay screenshottable for local UI
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:18:08 +02:00

993 lines
37 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, pickByDefaultQuality } from './PlaybackQualityModal';
import { usePreferences } from '../PreferencesContext';
export function SceneDetailScreen() {
const client = useClient();
const queryClient = useQueryClient();
const route = useRoute<RouteProp<RootStackParamList, 'SceneDetail'>>();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const { id, autoplay } = 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, i) => (
<PlaybackButton
key={p.id}
sceneId={data.id}
sceneDurationSec={data.duration_sec ?? null}
source={p}
// Quick-play: pierwsze źródło (backend-ranked native-first) odpala się samo.
// onAutoPlayConsumed czyści param autoplay, żeby reorder listy (np. po
// "Mark broken" usuwa martwe źródło) nie odpalał kolejnego źródła sam
// z siebie, bo nowy i===0 dostałby autoPlay=true (review 2/6).
autoPlay={!!autoplay && i === 0}
onAutoPlayConsumed={() => nav.setParams({ autoplay: false })}
/>
))}
<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,
autoPlay = false,
onAutoPlayConsumed,
}: {
sceneId: string;
sceneDurationSec: number | null;
source: PlaybackSource;
autoPlay?: boolean;
onAutoPlayConsumed?: () => void;
}) {
const client = useClient();
const queryClient = useQueryClient();
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList, 'SceneDetail'>>();
const { defaultQuality } = usePreferences();
const [resolving, setResolving] = React.useState(false);
const [qualityLinks, setQualityLinks] = React.useState<StreamLink[] | null>(null);
// Auto-quality: 1 link → graj; wiele → spróbuj dopasować domyślną jakość usera
// (null gdy 'auto'/brak dopasowania → pokaż picker jak dotąd). markStarted zostaje
// per call-site (różni się: pornxp/phone woła przed pickerem, backend tylko gdy gra).
const pickAuto = (list: StreamLink[]): StreamLink | null => {
if (list.length === 1) return list[0];
return pickByDefaultQuality(list, defaultQuality);
};
// 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;
}
// Phone-side resolve dla IP-bound tubów: CDN token jest bound do IP które POBRAŁO
// stronę (audit 2026-06-11). Backend resolvuje z VPS → telefon dostaje URL bound do IP
// VPS → direct 403 / 10B placeholder. Telefon sam pobiera stronę (phone IP) → token
// bound do telefonu → gra direct, zero VPS. pornxp.ph analogicznie (backend WebView
// fallback czarno-ekranił, fd06cd86). Pusto → spadnij na backend resolve niżej.
const phoneResolver =
source.origin === 'tube:sxyprncom'
? resolveSxyprnPage
: source.origin === 'tube:epornercom'
? resolveEpornerPage
: source.origin === 'tube:fpoxxx'
? resolveFpoxxxPage
: source.origin === 'tube:pornxpph'
? resolvePornxpPage
: null;
if (phoneResolver) {
setResolving(true);
try {
const links = await phoneResolver(source.page_url);
if (links.length > 0) {
markStarted();
const pick = pickAuto(links);
if (pick) await openAsVideo(pick, 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`. Źródła natywne-mp4 bez embedu (KVS:
// watchporn) NIE spadają na page_url w WebView — zamiast tego Player re-resolwuje
// scenę świeżo przez backend (świeży żywy nod CDN + token), zostając przy natywnym
// direct streamie (patrz BACKEND_RERESOLVE_ORIGINS w PlayerScreen).
const fallbackEmbedUrl =
embedLinks[0]?.embed_url || res.best?.embed_url || undefined;
const autoPick = pickAuto(directLinks);
if (autoPick) {
markStarted();
await openAsVideo(autoPick, 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);
}
};
// Quick-play z kafelka (▶ → SceneDetail autoplay): odpal to źródło raz na mount.
// onPress ma pełną logikę resolve (phone-side/backend + quality picker + fallbacki),
// więc reużywamy ją w kontekście SceneDetail zamiast duplikować gdziekolwiek indziej.
const didAutoPlayRef = React.useRef(false);
React.useEffect(() => {
if (autoPlay && !didAutoPlayRef.current) {
didAutoPlayRef.current = true;
onAutoPlayConsumed?.(); // wyczyść param, zanim reorder listy odpali kolejne źródło
void onPress();
}
// onPress celowo poza deps, strzał raz na mount, nie przy każdym re-renderze.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPlay]);
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 (incognito)',
onPress: async () => {
try {
const url = source.page_url || source.embed_url || source.stream_url;
if (url) {
// In-app incognito WebView (report dccc05e4): świeży widok bez sesji/
// cookies usera i bez śladu w prawdziwej przeglądarce. Zamiast
// Linking.openURL (otwierał Chrome z historią + sesją).
nav.navigate('DiagnosticBrowser', { 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 },
});