// 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 } from '../types'; 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(); 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. const target = 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, // Player używa sceneId do progress tracking; movies progress przyjdzie później. // Na razie pass movie id — backend /scenes/{id}/progress zwróci 404, mobile silently catch. sceneId: movieId, durationSec: pb.duration_sec ?? null, title, mode: res.best?.stream_url ? 'video' : 'webview', fallbackEmbedUrl: fallbackEmbed, }); }, onError: (e: any) => { Alert.alert('Resolve failed', e?.message ?? 'unknown error'); }, }); const onLongPress = () => { Alert.alert( pb.origin, 'Co zrobić z tym linkiem?', [ { text: 'Otwórz w przeglądarce (diagnostyka)', onPress: async () => { try { const url = pb.page_url || pb.embed_url; if (url) { await Linking.openURL(url); } else { Alert.alert('Brak URL', 'Ten playback nie ma page_url do otworzenia.'); } } catch (e) { Alert.alert('Nie udało się otworzyć', e instanceof Error ? e.message : String(e)); } }, }, { text: 'Oznacz jako nieprawidłowy', style: 'destructive', onPress: async () => { try { await client.markMoviePlaybackDead(movieId, pb.id); queryClient.invalidateQueries({ queryKey: ['movie', movieId] }); } catch (e) { Alert.alert('Nie udało się', e instanceof Error ? e.message : String(e)); } }, }, { text: 'Anuluj', style: 'cancel' }, ], ); }; return ( resolveMutation.mutate()} onLongPress={onLongPress} delayLongPress={500} disabled={resolveMutation.isPending} > ▶ {pb.origin} ); } 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 }, });