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.
315 lines
11 KiB
TypeScript
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 },
|
|
});
|