// 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>(); const navigation = useNavigation>(); 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: () => ( favMutation.mutate()} hitSlop={12} disabled={favMutation.isPending} > {isFav ? '★' : '☆'} ), }); }, [navigation, data?.title, isFav, favMutation]); if (isLoading) { return ( ); } if (error instanceof Error) { return ( {error.message} ); } 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 ( {data.poster_url ? ( ) : ( )} {data.title} {data.release_year ?? '—'} {data.studio?.name ? ` · ${data.studio.name}` : ''} {dur ? ` · ${dur}` : ''} {data.director ? dir. {data.director} : null} {data.country ? {data.country} : null} {data.rating != null ? ( ★ {data.rating.toFixed(1)}/10 ) : null} {data.description ? ( {data.description} ) : null} {data.performers.length > 0 ? ( Cast {data.performers.map((p) => ( navigation.navigate('PerformerScenes', { id: p.id, name: p.canonical_name }) } > {p.canonical_name} ))} ) : null} {data.tags.length > 0 ? ( Genres {data.tags.map((t) => ( {t.name} ))} ) : null} {data.chapters.length > 0 ? ( Chapters ({data.chapters.length}) {data.chapters.map((c) => ( {c.chapter_index + 1}. {c.title ?? `Part ${c.chapter_index + 1}`} {c.start_sec != null ? ( {formatTime(c.start_sec)} ) : null} ))} ) : null} {data.playback_sources.length > 0 ? ( Watch on ({data.playback_sources.length}) {data.playback_sources.map((p) => ( ))} long-press: open in browser / mark as broken ) : null} ); } function WatchChip({ pb, movieId, title, }: { pb: PlaybackSource; movieId: string; title: string; }) { const client = useClient(); const navigation = useNavigation>(); 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(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 ( <> (pb.origin.endsWith(':voe') ? playVoe() : resolveMutation.mutate())} onLongPress={onLongPress} delayLongPress={500} disabled={resolveMutation.isPending || voeResolving} > ▶ {pb.origin} 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 }, });