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 { 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,6 +299,7 @@ function WatchChip({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.watchChip, resolveMutation.isPending && styles.watchChipLoading]}
|
style={[styles.watchChip, resolveMutation.isPending && styles.watchChipLoading]}
|
||||||
onPress={() => resolveMutation.mutate()}
|
onPress={() => resolveMutation.mutate()}
|
||||||
|
|
@ -308,6 +309,15 @@ function WatchChip({
|
||||||
>
|
>
|
||||||
<Text style={styles.watchChipText}>▶ {pb.origin}</Text>
|
<Text style={styles.watchChipText}>▶ {pb.origin}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<PlaybackQualityModal
|
||||||
|
visible={!!partsPicker}
|
||||||
|
links={partsPicker ?? []}
|
||||||
|
title="Select part"
|
||||||
|
preserveOrder
|
||||||
|
onSelect={playPart}
|
||||||
|
onCancel={() => setPartsPicker(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,7 +63,11 @@ 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>
|
||||||
|
{/* 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) => {
|
{sorted.map((link, i) => {
|
||||||
const px = qualityToInt(link.quality);
|
const px = qualityToInt(link.quality);
|
||||||
return (
|
return (
|
||||||
|
|
@ -63,12 +80,13 @@ export function PlaybackQualityModal({
|
||||||
{link.quality || 'unknown'}
|
{link.quality || 'unknown'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.rowMeta}>
|
<Text style={styles.rowMeta}>
|
||||||
{px ? `${px}p` : ''}
|
{px >= 144 ? `${px}p` : ''}
|
||||||
{link.type ? ` · ${shortType(link.type)}` : ''}
|
{link.type ? ` · ${shortType(link.type)}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue