fix(mobile): movie part picker as scrollable modal — Android showed only 3 of N

paradisehill multipart movies passed all N parts to Alert.alert, but Android's
native AlertDialog renders at most 3 buttons → a 35-part movie showed 3 (bug-report
2ebd0690 2026-06-07). Backend correctly returns all 35; the cap was client-side.
Reuse PlaybackQualityModal (now scrollable + title + preserveOrder props, hides
bogus "1p" for non-resolution labels). Also add the missing `raw` field to the
StreamLink type (backend sends it; part_label lives there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-08 10:25:03 +02:00
parent 940d4872e3
commit 8f34a3e2f1
3 changed files with 92 additions and 59 deletions

View file

@ -17,7 +17,8 @@ import {
import { useClient } from '../ClientContext'; import { useClient } from '../ClientContext';
import type { RootStackParamList } from '../navigation'; import type { RootStackParamList } from '../navigation';
import { theme } from '../theme'; import { theme } from '../theme';
import type { PlaybackSource } from '../types'; import type { PlaybackSource, StreamLink } from '../types';
import { PlaybackQualityModal } from './PlaybackQualityModal';
export function MovieDetailScreen() { export function MovieDetailScreen() {
const client = useClient(); const client = useClient();
@ -184,6 +185,32 @@ function WatchChip({
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>();
const queryClient = useQueryClient(); 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],
);
const resolveMutation = useMutation({ const resolveMutation = useMutation({
mutationFn: () => client.resolveMoviePlayback(movieId, pb.id), mutationFn: () => client.resolveMoviePlayback(movieId, pb.id),
onSuccess: (res) => { onSuccess: (res) => {
@ -198,35 +225,8 @@ function WatchChip({
const links = res.links ?? []; const links = res.links ?? [];
const parts = links.filter((l) => l.raw && typeof l.raw === 'object' && (l.raw as any).part_label); const parts = links.filter((l) => l.raw && typeof l.raw === 'object' && (l.raw as any).part_label);
if (parts.length > 1) { if (parts.length > 1) {
Alert.alert( // Scrollowalny modal (nie Alert.alert — Android capuje do 3 buttonów).
title, setPartsPicker(parts);
'This movie has several parts. Choose which one to start.',
[
...parts.map((p) => ({
text: ((p.raw as any).part_label as string) ?? p.quality ?? 'Part',
onPress: () => {
// Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy. paradisehill
// parts to mp4 z portable CDN (v1.paradisehill.cc, time-bound) → direct gra
// natywnie, proxy tylko gdyby direct padł. Mirror scen (SceneDetailScreen).
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,
});
},
})),
{ text: 'Cancel', style: 'cancel' as const },
],
);
return; return;
} }
@ -299,15 +299,25 @@ function WatchChip({
}; };
return ( return (
<Pressable <>
style={[styles.watchChip, resolveMutation.isPending && styles.watchChipLoading]} <Pressable
onPress={() => resolveMutation.mutate()} style={[styles.watchChip, resolveMutation.isPending && styles.watchChipLoading]}
onLongPress={onLongPress} onPress={() => resolveMutation.mutate()}
delayLongPress={500} onLongPress={onLongPress}
disabled={resolveMutation.isPending} delayLongPress={500}
> disabled={resolveMutation.isPending}
<Text style={styles.watchChipText}> {pb.origin}</Text> >
</Pressable> <Text style={styles.watchChipText}> {pb.origin}</Text>
</Pressable>
<PlaybackQualityModal
visible={!!partsPicker}
links={partsPicker ?? []}
title="Select part"
preserveOrder
onSelect={playPart}
onCancel={() => setPartsPicker(null)}
/>
</>
); );
} }

View file

@ -4,6 +4,7 @@ import React from 'react';
import { import {
Modal, Modal,
Pressable, Pressable,
ScrollView,
StyleSheet, StyleSheet,
Text, Text,
View, View,
@ -33,13 +34,25 @@ export function PlaybackQualityModal({
links, links,
onSelect, onSelect,
onCancel, onCancel,
title = 'Select quality',
/**
* preserveOrder=true nie sortuj (zachowaj kolejność z `links`). Używane dla
* paradisehill multipart picker gdzie chcemy Part 1..N rosnąco, nie po "quality"
* (qualityToInt("Part 35")=35 sortowałby części od końca).
*/
preserveOrder = false,
}: { }: {
visible: boolean; visible: boolean;
links: StreamLink[]; links: StreamLink[];
onSelect: (link: StreamLink) => void; onSelect: (link: StreamLink) => void;
onCancel: () => void; onCancel: () => void;
title?: string;
preserveOrder?: boolean;
}) { }) {
const sorted = React.useMemo(() => sortByQualityDesc(links), [links]); const sorted = React.useMemo(
() => (preserveOrder ? links : sortByQualityDesc(links)),
[links, preserveOrder],
);
return ( return (
<Modal <Modal
@ -50,25 +63,30 @@ export function PlaybackQualityModal({
> >
<Pressable style={styles.backdrop} onPress={onCancel}> <Pressable style={styles.backdrop} onPress={onCancel}>
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}> <Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
<Text style={styles.title}>Select quality</Text> <Text style={styles.title}>{title}</Text>
{sorted.map((link, i) => { {/* ScrollView: paradisehill movie może mieć 35+ parts bez scrolla sheet
const px = qualityToInt(link.quality); przekraczał ekran i część była nieosiągalna. (Android Alert.alert capował
return ( do 3 buttonów bug-report 2ebd0690 2026-06-07 "35 parts, 3 w popup".) */}
<Pressable <ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
key={`${link.stream_url || link.embed_url}-${i}`} {sorted.map((link, i) => {
style={styles.row} const px = qualityToInt(link.quality);
onPress={() => onSelect(link)} return (
> <Pressable
<Text style={styles.rowQuality}> key={`${link.stream_url || link.embed_url}-${i}`}
{link.quality || 'unknown'} style={styles.row}
</Text> onPress={() => onSelect(link)}
<Text style={styles.rowMeta}> >
{px ? `${px}p` : ''} <Text style={styles.rowQuality}>
{link.type ? ` · ${shortType(link.type)}` : ''} {link.quality || 'unknown'}
</Text> </Text>
</Pressable> <Text style={styles.rowMeta}>
); {px >= 144 ? `${px}p` : ''}
})} {link.type ? ` · ${shortType(link.type)}` : ''}
</Text>
</Pressable>
);
})}
</ScrollView>
<Pressable style={styles.cancel} onPress={onCancel}> <Pressable style={styles.cancel} onPress={onCancel}>
<Text style={styles.cancelText}>Cancel</Text> <Text style={styles.cancelText}>Cancel</Text>
</Pressable> </Pressable>
@ -103,6 +121,8 @@ const styles = StyleSheet.create({
borderTopColor: theme.border, borderTopColor: theme.border,
borderTopWidth: 1, borderTopWidth: 1,
}, },
list: { maxHeight: 380 },
listContent: { paddingBottom: 2 },
title: { title: {
color: theme.muted, color: theme.muted,
fontSize: 12, fontSize: 12,

View file

@ -52,6 +52,9 @@ export interface StreamLink {
headers?: Record<string, string> | null; headers?: Record<string, string> | null;
quality?: string | null; quality?: string | null;
type?: string | null; type?: string | null;
// Backend metadata (origin, host, part_label dla paradisehill multipart, mobile_direct_ok...).
// Untyped bag — mobile czyta selektywnie (np. raw.part_label w movie part-pickerze).
raw?: Record<string, unknown> | null;
} }
export interface ResolveOut { export interface ResolveOut {