goon/mobile/src/screens/MovieDetailScreen.tsx
goon-foss ad0284585b Initial commit
Goon — self-hosted aggregator for adult-content scene metadata.

Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites.
Cross-source deduplication via perceptual hash + Levenshtein distance.
FastAPI backend + APScheduler worker + React Native (Expo) mobile client.

FOSS, ad-free, donation-funded. See README for details.
2026-05-20 10:10:22 +02:00

315 lines
11 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 } from '../types';
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();
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 (
<Pressable
style={[styles.watchChip, resolveMutation.isPending && styles.watchChipLoading]}
onPress={() => resolveMutation.mutate()}
onLongPress={onLongPress}
delayLongPress={500}
disabled={resolveMutation.isPending}
>
<Text style={styles.watchChipText}> {pb.origin}</Text>
</Pressable>
);
}
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 },
});