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:
parent
940d4872e3
commit
8f34a3e2f1
3 changed files with 92 additions and 59 deletions
|
|
@ -17,7 +17,8 @@ import {
|
|||
import { useClient } from '../ClientContext';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
import { theme } from '../theme';
|
||||
import type { PlaybackSource } from '../types';
|
||||
import type { PlaybackSource, StreamLink } from '../types';
|
||||
import { PlaybackQualityModal } from './PlaybackQualityModal';
|
||||
|
||||
export function MovieDetailScreen() {
|
||||
const client = useClient();
|
||||
|
|
@ -184,6 +185,32 @@ function WatchChip({
|
|||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'MovieDetail'>>();
|
||||
|
||||
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({
|
||||
mutationFn: () => client.resolveMoviePlayback(movieId, pb.id),
|
||||
onSuccess: (res) => {
|
||||
|
|
@ -198,35 +225,8 @@ function WatchChip({
|
|||
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) {
|
||||
Alert.alert(
|
||||
title,
|
||||
'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 },
|
||||
],
|
||||
);
|
||||
// Scrollowalny modal (nie Alert.alert — Android capuje do 3 buttonów).
|
||||
setPartsPicker(parts);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -299,15 +299,25 @@ function WatchChip({
|
|||
};
|
||||
|
||||
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>
|
||||
<>
|
||||
<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>
|
||||
<PlaybackQualityModal
|
||||
visible={!!partsPicker}
|
||||
links={partsPicker ?? []}
|
||||
title="Select part"
|
||||
preserveOrder
|
||||
onSelect={playPart}
|
||||
onCancel={() => setPartsPicker(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import React from 'react';
|
|||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
|
|
@ -33,13 +34,25 @@ export function PlaybackQualityModal({
|
|||
links,
|
||||
onSelect,
|
||||
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;
|
||||
links: StreamLink[];
|
||||
onSelect: (link: StreamLink) => 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 (
|
||||
<Modal
|
||||
|
|
@ -50,25 +63,30 @@ export function PlaybackQualityModal({
|
|||
>
|
||||
<Pressable style={styles.backdrop} onPress={onCancel}>
|
||||
<Pressable style={styles.sheet} onPress={(e) => e.stopPropagation()}>
|
||||
<Text style={styles.title}>Select quality</Text>
|
||||
{sorted.map((link, i) => {
|
||||
const px = qualityToInt(link.quality);
|
||||
return (
|
||||
<Pressable
|
||||
key={`${link.stream_url || link.embed_url}-${i}`}
|
||||
style={styles.row}
|
||||
onPress={() => onSelect(link)}
|
||||
>
|
||||
<Text style={styles.rowQuality}>
|
||||
{link.quality || 'unknown'}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{px ? `${px}p` : ''}
|
||||
{link.type ? ` · ${shortType(link.type)}` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{/* ScrollView: paradisehill movie może mieć 35+ parts — bez scrolla sheet
|
||||
przekraczał ekran i część była nieosiągalna. (Android Alert.alert capował
|
||||
do 3 buttonów — bug-report 2ebd0690 2026-06-07 "35 parts, 3 w popup".) */}
|
||||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
|
||||
{sorted.map((link, i) => {
|
||||
const px = qualityToInt(link.quality);
|
||||
return (
|
||||
<Pressable
|
||||
key={`${link.stream_url || link.embed_url}-${i}`}
|
||||
style={styles.row}
|
||||
onPress={() => onSelect(link)}
|
||||
>
|
||||
<Text style={styles.rowQuality}>
|
||||
{link.quality || 'unknown'}
|
||||
</Text>
|
||||
<Text style={styles.rowMeta}>
|
||||
{px >= 144 ? `${px}p` : ''}
|
||||
{link.type ? ` · ${shortType(link.type)}` : ''}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<Pressable style={styles.cancel} onPress={onCancel}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
|
|
@ -103,6 +121,8 @@ const styles = StyleSheet.create({
|
|||
borderTopColor: theme.border,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
list: { maxHeight: 380 },
|
||||
listContent: { paddingBottom: 2 },
|
||||
title: {
|
||||
color: theme.muted,
|
||||
fontSize: 12,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ export interface StreamLink {
|
|||
headers?: Record<string, string> | null;
|
||||
quality?: 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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue