These CDNs bind their signed video URL to the IP that fetched the page, so a server-side resolve hands the phone a URL bound to the server IP -- the device then gets a placeholder/403 and falls back through the proxy, streaming the whole video through the server. Resolve on the device instead (token binds to the phone IP) so playback goes direct with zero proxy bandwidth. Ports of the existing backend extractors: - sxyprnResolver.ts: data-vnfo + boo/ssut51 transform - epornerResolver.ts: vid+hash -> /xhr/video mp4 sources - voeResolver.ts: mirror redirect + 7-step payload decoder Wired into SceneDetailScreen.onPress (sxyprn/eporner) and MovieDetailScreen.playVoe (voe). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
// Detail filmu — plakat, opis, cast, chaptery, mirrory.
|
|
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Image } from 'expo-image';
|
|
import React from 'react';
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Linking,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native';
|
|
import { useClient } from '../ClientContext';
|
|
import type { RootStackParamList } from '../navigation';
|
|
import { theme } from '../theme';
|
|
import type { PlaybackSource, StreamLink } from '../types';
|
|
import { resolveVoePage } from '../lib/voeResolver';
|
|
import { PlaybackQualityModal } from './PlaybackQualityModal';
|
|
|
|
export function MovieDetailScreen() {
|
|
const client = useClient();
|
|
const queryClient = useQueryClient();
|
|
const route = useRoute<RouteProp<RootStackParamList, 'MovieDetail'>>();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>();
|
|
const { id } = route.params;
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['movie', id],
|
|
queryFn: () => client.getMovie(id),
|
|
});
|
|
|
|
const isFav = data?.is_favorite ?? false;
|
|
const favMutation = useMutation({
|
|
mutationFn: () =>
|
|
isFav ? client.removeFavoriteMovie(id) : client.addFavoriteMovie(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['movie', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['favorites-movies'] });
|
|
},
|
|
});
|
|
|
|
React.useLayoutEffect(() => {
|
|
navigation.setOptions({
|
|
title: data?.title ?? 'Movie',
|
|
headerRight: () => (
|
|
<Pressable
|
|
onPress={() => favMutation.mutate()}
|
|
hitSlop={12}
|
|
disabled={favMutation.isPending}
|
|
>
|
|
<Text style={{ color: isFav ? theme.accent : theme.muted, fontSize: 22 }}>
|
|
{isFav ? '★' : '☆'}
|
|
</Text>
|
|
</Pressable>
|
|
),
|
|
});
|
|
}, [navigation, data?.title, isFav, favMutation]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<ActivityIndicator color={theme.fg} />
|
|
</View>
|
|
);
|
|
}
|
|
if (error instanceof Error) {
|
|
return (
|
|
<View style={styles.center}>
|
|
<Text style={styles.errorText}>{error.message}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (!data) return null;
|
|
|
|
const dur = data.duration_sec
|
|
? `${Math.floor(data.duration_sec / 3600) > 0 ? `${Math.floor(data.duration_sec / 3600)}h ` : ''}${Math.floor((data.duration_sec % 3600) / 60)}min`
|
|
: null;
|
|
|
|
return (
|
|
<ScrollView style={styles.container} contentContainerStyle={{ paddingBottom: 32 }}>
|
|
<View style={styles.heroRow}>
|
|
{data.poster_url ? (
|
|
<Image source={{ uri: data.poster_url }} style={styles.poster} contentFit="cover" />
|
|
) : (
|
|
<View style={[styles.poster, styles.posterPlaceholder]} />
|
|
)}
|
|
<View style={styles.heroMeta}>
|
|
<Text style={styles.title}>{data.title}</Text>
|
|
<Text style={styles.subtitle}>
|
|
{data.release_year ?? '—'}
|
|
{data.studio?.name ? ` · ${data.studio.name}` : ''}
|
|
{dur ? ` · ${dur}` : ''}
|
|
</Text>
|
|
{data.director ? <Text style={styles.subtitle}>dir. {data.director}</Text> : null}
|
|
{data.country ? <Text style={styles.subtitle}>{data.country}</Text> : null}
|
|
{data.rating != null ? (
|
|
<Text style={styles.rating}>★ {data.rating.toFixed(1)}/10</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
{data.description ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.body}>{data.description}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{data.performers.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Cast</Text>
|
|
<View style={styles.chipRow}>
|
|
{data.performers.map((p) => (
|
|
<Pressable
|
|
key={p.id}
|
|
style={styles.chip}
|
|
onPress={() =>
|
|
navigation.navigate('PerformerScenes', { id: p.id, name: p.canonical_name })
|
|
}
|
|
>
|
|
<Text style={styles.chipText}>{p.canonical_name}</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{data.tags.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Genres</Text>
|
|
<View style={styles.chipRow}>
|
|
{data.tags.map((t) => (
|
|
<View key={t.id} style={styles.tagChip}>
|
|
<Text style={styles.chipText}>{t.name}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{data.chapters.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>Chapters ({data.chapters.length})</Text>
|
|
{data.chapters.map((c) => (
|
|
<View key={c.chapter_index} style={styles.chapterRow}>
|
|
<Text style={styles.chapterIndex}>{c.chapter_index + 1}.</Text>
|
|
<Text style={styles.chapterTitle}>{c.title ?? `Part ${c.chapter_index + 1}`}</Text>
|
|
{c.start_sec != null ? (
|
|
<Text style={styles.chapterTime}>{formatTime(c.start_sec)}</Text>
|
|
) : null}
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : null}
|
|
|
|
{data.playback_sources.length > 0 ? (
|
|
<View style={styles.section}>
|
|
<Text style={styles.sectionTitle}>
|
|
Watch on ({data.playback_sources.length})
|
|
</Text>
|
|
<View style={styles.chipRow}>
|
|
{data.playback_sources.map((p) => (
|
|
<WatchChip key={p.id} pb={p} movieId={data.id} title={data.title} />
|
|
))}
|
|
</View>
|
|
<Text style={styles.chipHint}>long-press: open in browser / mark as broken</Text>
|
|
</View>
|
|
) : null}
|
|
</ScrollView>
|
|
);
|
|
}
|
|
|
|
function WatchChip({
|
|
pb,
|
|
movieId,
|
|
title,
|
|
}: {
|
|
pb: PlaybackSource;
|
|
movieId: string;
|
|
title: string;
|
|
}) {
|
|
const client = useClient();
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>();
|
|
|
|
const queryClient = useQueryClient();
|
|
// Multipart picker — paradisehill movie z N częściami. Modal zamiast Alert.alert,
|
|
// bo Androidowy AlertDialog renderuje max 3 buttony (bug-report 2ebd0690: "35 parts,
|
|
// 3 w popup"). PlaybackQualityModal jest przewijalny i pokazuje wszystkie.
|
|
const [partsPicker, setPartsPicker] = React.useState<StreamLink[] | null>(null);
|
|
|
|
const playPart = React.useCallback(
|
|
(p: StreamLink) => {
|
|
setPartsPicker(null);
|
|
const pDirect = p.direct_url;
|
|
const pIsDirect = !!pDirect && pDirect !== p.stream_url;
|
|
navigation.navigate('Player', {
|
|
url: pDirect || p.stream_url || p.embed_url || pb.page_url,
|
|
sceneId: movieId,
|
|
playbackId: pb.id,
|
|
entityKind: 'movie',
|
|
durationSec: pb.duration_sec ?? null,
|
|
title: `${title} — ${(p.raw as any)?.part_label ?? p.quality}`,
|
|
mode: (p.direct_url || p.stream_url) ? 'video' : 'webview',
|
|
headers: pIsDirect && p.headers ? p.headers : undefined,
|
|
fallbackProxyUrl: pIsDirect ? p.stream_url || undefined : undefined,
|
|
fallbackEmbedUrl: p.embed_url || pb.embed_url || pb.page_url,
|
|
});
|
|
},
|
|
[navigation, pb, movieId, title],
|
|
);
|
|
|
|
// VOE: token CDN bound do /16 IP które pobrało embed (z VPS = i=46.62 → telefon 403
|
|
// → pełny proxy → wideo przez Hetzner, 131 hitów/48h). Telefon sam pobiera embed →
|
|
// token bound do jego /16 → gra direct, zero VPS. [] → spadnij na backend resolve.
|
|
const [voeResolving, setVoeResolving] = React.useState(false);
|
|
const playVoe = React.useCallback(async () => {
|
|
setVoeResolving(true);
|
|
try {
|
|
const links = await resolveVoePage(pb.page_url || pb.embed_url || '');
|
|
const best = links[0];
|
|
if (best && (best.direct_url || best.stream_url)) {
|
|
navigation.navigate('Player', {
|
|
url: best.direct_url || best.stream_url!,
|
|
sceneId: movieId,
|
|
playbackId: pb.id,
|
|
entityKind: 'movie',
|
|
durationSec: pb.duration_sec ?? null,
|
|
title,
|
|
headers: best.headers ?? undefined,
|
|
// Zero VPS: bez fallbackProxyUrl (proxy = IP-bound do VPS, bez sensu). Na błąd
|
|
// → WebView z embed page (voe player sam pobierze m3u8 w swoim kontekście).
|
|
fallbackEmbedUrl: pb.page_url || pb.embed_url || undefined,
|
|
});
|
|
return;
|
|
}
|
|
} catch {
|
|
// ignore → backend fallback
|
|
} finally {
|
|
setVoeResolving(false);
|
|
}
|
|
resolveMutation.mutate();
|
|
}, [pb, movieId, title, navigation]);
|
|
|
|
const resolveMutation = useMutation({
|
|
mutationFn: () => client.resolveMoviePlayback(movieId, pb.id),
|
|
onSuccess: (res) => {
|
|
// best.stream_url to backend proxy URL gdy direct video się udało wyciągnąć,
|
|
// lub embed_url gdy hoster nieudany — Player handler-uje obie ścieżki.
|
|
// _absolutizeProxyUrls w GoonClient już prefixuje /proxy/... baseUrl-em.
|
|
|
|
// Multipart: paradisehill movies mają `videoList` z N MP4 parts. Backend
|
|
// zwraca każdy jako StreamLink z `quality = "Part N"` + `raw.part_label`.
|
|
// Bez part-picker mobile używałby tylko best (Part 1) — user nie miałby
|
|
// dostępu do reszty (bug-reports `c5693926`/`418270e4` 2026-05-21).
|
|
const links = res.links ?? [];
|
|
const parts = links.filter((l) => l.raw && typeof l.raw === 'object' && (l.raw as any).part_label);
|
|
if (parts.length > 1) {
|
|
// Scrollowalny modal (nie Alert.alert — Android capuje do 3 buttonów).
|
|
setPartsPicker(parts);
|
|
return;
|
|
}
|
|
|
|
// Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy gdy direct fails.
|
|
// seekplayer-engine (#hash family, ~322k źródeł) zwraca master.m3u8 na raw-IP CDN
|
|
// z VALID ZeroSSL IP-SAN cert + time-bound token — zweryfikowane na emulatorze
|
|
// (ExoPlayer gra direct, PLAYING, zero VPS proxy). Wcześniej movie path szedł
|
|
// ZAWSZE przez proxy (używał stream_url jako primary). Mirror SceneDetailScreen.
|
|
const bestDirect = res.best?.direct_url;
|
|
const isDirect = !!bestDirect && bestDirect !== res.best?.stream_url;
|
|
const target = bestDirect || res.best?.stream_url || res.best?.embed_url || pb.page_url;
|
|
const fallbackEmbed = res.best?.embed_url || pb.embed_url || pb.page_url;
|
|
navigation.navigate('Player', {
|
|
url: target,
|
|
// sceneId pozostaje nazwą param-u (legacy z kiedy Player obsługiwał tylko sceny),
|
|
// ale dla entityKind='movie' Player rzutuje to do /movies/{id}/progress.
|
|
// Bug-report b207ff17 2026-05-26 ("oznaczenie obejrzanych filmów") — backend
|
|
// dostał movie_play_progress 2026-05-28.
|
|
sceneId: movieId,
|
|
playbackId: pb.id,
|
|
entityKind: 'movie',
|
|
durationSec: pb.duration_sec ?? null,
|
|
title,
|
|
mode: (res.best?.direct_url || res.best?.stream_url) ? 'video' : 'webview',
|
|
headers: isDirect && res.best?.headers ? res.best.headers : undefined,
|
|
fallbackProxyUrl: isDirect ? res.best?.stream_url || undefined : undefined,
|
|
fallbackEmbedUrl: fallbackEmbed,
|
|
});
|
|
},
|
|
onError: (e: any) => {
|
|
Alert.alert('Resolve failed', e?.message ?? 'unknown error');
|
|
},
|
|
});
|
|
|
|
const onLongPress = () => {
|
|
Alert.alert(
|
|
pb.origin,
|
|
'What do you want to do with this link?',
|
|
[
|
|
{
|
|
text: 'Open in browser (diagnostics)',
|
|
onPress: async () => {
|
|
try {
|
|
const url = pb.page_url || pb.embed_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.markMoviePlaybackDead(movieId, pb.id);
|
|
queryClient.invalidateQueries({ queryKey: ['movie', movieId] });
|
|
} catch (e) {
|
|
Alert.alert('Failed', e instanceof Error ? e.message : String(e));
|
|
}
|
|
},
|
|
},
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
],
|
|
);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Pressable
|
|
style={[styles.watchChip, (resolveMutation.isPending || voeResolving) && styles.watchChipLoading]}
|
|
onPress={() => (pb.origin.endsWith(':voe') ? playVoe() : resolveMutation.mutate())}
|
|
onLongPress={onLongPress}
|
|
delayLongPress={500}
|
|
disabled={resolveMutation.isPending || voeResolving}
|
|
>
|
|
<Text style={styles.watchChipText}>▶ {pb.origin}</Text>
|
|
</Pressable>
|
|
<PlaybackQualityModal
|
|
visible={!!partsPicker}
|
|
links={partsPicker ?? []}
|
|
title="Select part"
|
|
preserveOrder
|
|
onSelect={playPart}
|
|
onCancel={() => setPartsPicker(null)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function formatTime(s: number): string {
|
|
const total = Math.floor(s);
|
|
const m = Math.floor(total / 60);
|
|
const sec = total % 60;
|
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: theme.bg },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
errorText: { color: theme.bad },
|
|
|
|
heroRow: { flexDirection: 'row', padding: 12, gap: 12 },
|
|
poster: { width: 120, aspectRatio: 2 / 3, borderRadius: 8, backgroundColor: theme.card },
|
|
posterPlaceholder: { width: 120 },
|
|
heroMeta: { flex: 1, gap: 4 },
|
|
title: { color: theme.fg, fontSize: 18, fontWeight: '700' },
|
|
subtitle: { color: theme.muted, fontSize: 13 },
|
|
rating: { color: theme.accent, fontSize: 14, fontWeight: '700', marginTop: 4 },
|
|
|
|
section: { paddingHorizontal: 14, paddingVertical: 10 },
|
|
sectionTitle: { color: theme.fg, fontSize: 14, fontWeight: '700', marginBottom: 8 },
|
|
body: { color: theme.fg, fontSize: 13, lineHeight: 19 },
|
|
|
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
|
chip: {
|
|
backgroundColor: theme.card,
|
|
borderColor: theme.border,
|
|
borderWidth: 1,
|
|
borderRadius: 14,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 5,
|
|
},
|
|
tagChip: {
|
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
borderRadius: 12,
|
|
paddingHorizontal: 9,
|
|
paddingVertical: 4,
|
|
},
|
|
chipText: { color: theme.fg, fontSize: 12 },
|
|
watchChip: {
|
|
backgroundColor: theme.accent,
|
|
borderRadius: 14,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 7,
|
|
},
|
|
watchChipLoading: { opacity: 0.5 },
|
|
watchChipText: { color: theme.fg, fontSize: 12, fontWeight: '700' },
|
|
chipHint: { color: theme.muted, fontSize: 11, marginTop: 8, fontStyle: 'italic' },
|
|
|
|
chapterRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 6, gap: 8 },
|
|
chapterIndex: { color: theme.muted, fontSize: 13, width: 28 },
|
|
chapterTitle: { color: theme.fg, fontSize: 14, flex: 1 },
|
|
chapterTime: { color: theme.muted, fontSize: 12 },
|
|
});
|