pornxp.ph serves direct <source> mp4 (360/720/1080p) on st.pornxp.sh whose path token is IP-bound to whoever fetched the PAGE (verified 2026-06-07: VPS-resolved URL → 403 cross-IP). Backend resolve was therefore impossible, so pornxpph fell to the WebView fallback which black-screened (bug-report fd06cd86). Fix: resolve on-device (same pattern as getfileResolver/doodstream) — the phone fetches the page, so tokens bind to the phone IP and play natively. New pornxpResolver.ts extracts the <source> mp4s into multi-quality StreamLinks; SceneDetail short-circuits tube:pornxpph to it before backend resolve, feeding the existing quality-picker + native player. Verified on emulator (live OTA): pornxpph scene → quality picker (1080/720/360) → native playback PLAYING (no WebView, no ads, no black screen). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
864 lines
31 KiB
TypeScript
864 lines
31 KiB
TypeScript
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 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'] });
|
||
},
|
||
});
|
||
|
||
// 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'] });
|
||
}
|
||
},
|
||
});
|
||
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]);
|
||
|
||
// 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} />
|
||
|
||
<View style={styles.body}>
|
||
{(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 }: { data: SceneOut }) {
|
||
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;
|
||
|
||
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}
|
||
/>
|
||
<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;
|
||
}
|
||
|
||
nav.navigate('Player', {
|
||
url: initialUrl,
|
||
sceneId,
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
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'] });
|
||
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,
|
||
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'] });
|
||
} 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 },
|
||
|
||
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 },
|
||
});
|