goon/mobile/src/screens/SceneDetailScreen.tsx
jtrzupek b18f07d90e feat(playback): native pornxp.ph via phone-side resolver (kills black screen)
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>
2026-06-07 14:58:40 +02:00

864 lines
31 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 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 },
});