From 8f34a3e2f1dd5d4587f4782710c756ae844acde7 Mon Sep 17 00:00:00 2001 From: jtrzupek Date: Mon, 8 Jun 2026 10:25:03 +0200 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20movie=20part=20picker=20as=20scr?= =?UTF-8?q?ollable=20modal=20=E2=80=94=20Android=20showed=20only=203=20of?= =?UTF-8?q?=20N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- mobile/src/screens/MovieDetailScreen.tsx | 88 ++++++++++++--------- mobile/src/screens/PlaybackQualityModal.tsx | 60 +++++++++----- mobile/src/types.ts | 3 + 3 files changed, 92 insertions(+), 59 deletions(-) diff --git a/mobile/src/screens/MovieDetailScreen.tsx b/mobile/src/screens/MovieDetailScreen.tsx index a11b719..3e1c9fe 100644 --- a/mobile/src/screens/MovieDetailScreen.tsx +++ b/mobile/src/screens/MovieDetailScreen.tsx @@ -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>(); 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(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 ( - resolveMutation.mutate()} - onLongPress={onLongPress} - delayLongPress={500} - disabled={resolveMutation.isPending} - > - ▶ {pb.origin} - + <> + resolveMutation.mutate()} + onLongPress={onLongPress} + delayLongPress={500} + disabled={resolveMutation.isPending} + > + ▶ {pb.origin} + + setPartsPicker(null)} + /> + ); } diff --git a/mobile/src/screens/PlaybackQualityModal.tsx b/mobile/src/screens/PlaybackQualityModal.tsx index 79819b9..4a07f93 100644 --- a/mobile/src/screens/PlaybackQualityModal.tsx +++ b/mobile/src/screens/PlaybackQualityModal.tsx @@ -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 ( e.stopPropagation()}> - Select quality - {sorted.map((link, i) => { - const px = qualityToInt(link.quality); - return ( - onSelect(link)} - > - - {link.quality || 'unknown'} - - - {px ? `${px}p` : ''} - {link.type ? ` · ${shortType(link.type)}` : ''} - - - ); - })} + {title} + {/* 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".) */} + + {sorted.map((link, i) => { + const px = qualityToInt(link.quality); + return ( + onSelect(link)} + > + + {link.quality || 'unknown'} + + + {px >= 144 ? `${px}p` : ''} + {link.type ? ` · ${shortType(link.type)}` : ''} + + + ); + })} + Cancel @@ -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, diff --git a/mobile/src/types.ts b/mobile/src/types.ts index adeed2a..d1581d7 100644 --- a/mobile/src/types.ts +++ b/mobile/src/types.ts @@ -52,6 +52,9 @@ export interface StreamLink { headers?: Record | 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 | null; } export interface ResolveOut {