mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 11:08:26 +01:00
@expo/ui's <Host> (SDK 55) fills its parent and reports its own size via setStyleSize, so it can't size to content. With the Host's flex:1 height depending on a zero-size wrapper, a circular dependency collapsed every selector nested more than one level deep — only the first (Quality) stayed visible in the download sheet. Pin the wrapper View to the measured trigger size and let the Host fill it via absoluteFill, breaking the cycle so Video/Audio/Subtitle render too.
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
import {
|
|
Button,
|
|
Host,
|
|
Menu,
|
|
Picker,
|
|
Text as SwiftUIText,
|
|
} from "@expo/ui/swift-ui";
|
|
import { disabled, tag } from "@expo/ui/swift-ui/modifiers";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
|
import React, { useEffect, useState } from "react";
|
|
import {
|
|
type LayoutChangeEvent,
|
|
Platform,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { Text } from "@/components/common/Text";
|
|
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
|
|
|
// Option types
|
|
export type RadioOption<T = any> = {
|
|
type: "radio";
|
|
label: string;
|
|
value: T;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export type ToggleOption = {
|
|
type: "toggle";
|
|
label: string;
|
|
value: boolean;
|
|
onToggle: () => void;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export type ActionOption = {
|
|
type: "action";
|
|
label: string;
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export type Option = RadioOption | ToggleOption | ActionOption;
|
|
|
|
// Option group structure
|
|
export type OptionGroup = {
|
|
title?: string;
|
|
options: Option[];
|
|
};
|
|
|
|
interface PlatformDropdownProps {
|
|
trigger?: React.ReactNode;
|
|
title?: string;
|
|
groups: OptionGroup[];
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
onOptionSelect?: (value?: any) => void;
|
|
expoUIConfig?: {
|
|
hostStyle?: any;
|
|
};
|
|
bottomSheetConfig?: {
|
|
enableDynamicSizing?: boolean;
|
|
enablePanDownToClose?: boolean;
|
|
};
|
|
}
|
|
|
|
const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
|
<View
|
|
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
|
>
|
|
<View
|
|
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
|
/>
|
|
</View>
|
|
);
|
|
|
|
const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
|
option,
|
|
isLast,
|
|
}) => {
|
|
const isToggle = option.type === "toggle";
|
|
const isAction = option.type === "action";
|
|
const handlePress = isToggle
|
|
? option.onToggle
|
|
: (option as RadioOption | ActionOption).onPress;
|
|
|
|
return (
|
|
<>
|
|
<TouchableOpacity
|
|
onPress={handlePress}
|
|
disabled={option.disabled}
|
|
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
|
|
>
|
|
<Text className='flex-1 text-white'>{option.label}</Text>
|
|
{isToggle ? (
|
|
<ToggleSwitch value={option.value} />
|
|
) : isAction ? null : (option as RadioOption).selected ? (
|
|
<Ionicons name='checkmark-circle' size={24} color='#9333ea' />
|
|
) : (
|
|
<Ionicons name='ellipse-outline' size={24} color='#6b7280' />
|
|
)}
|
|
</TouchableOpacity>
|
|
{!isLast && (
|
|
<View
|
|
style={{
|
|
height: StyleSheet.hairlineWidth,
|
|
}}
|
|
className='bg-neutral-700 mx-4'
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => (
|
|
<View className='mb-6'>
|
|
{group.title && (
|
|
<Text className='text-lg font-semibold mb-3 text-neutral-300'>
|
|
{group.title}
|
|
</Text>
|
|
)}
|
|
<View
|
|
style={{
|
|
borderRadius: 12,
|
|
overflow: "hidden",
|
|
}}
|
|
className='bg-neutral-800 rounded-xl overflow-hidden'
|
|
>
|
|
{group.options.map((option, index) => (
|
|
<OptionItem
|
|
key={index}
|
|
option={option}
|
|
isLast={index === group.options.length - 1}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
|
|
const BottomSheetContent: React.FC<{
|
|
title?: string;
|
|
groups: OptionGroup[];
|
|
onOptionSelect?: (value?: any) => void;
|
|
onClose?: () => void;
|
|
}> = ({ title, groups, onOptionSelect, onClose }) => {
|
|
const insets = useSafeAreaInsets();
|
|
|
|
// Wrap the groups to call onOptionSelect when an option is pressed
|
|
const wrappedGroups = groups.map((group) => ({
|
|
...group,
|
|
options: group.options.map((option) => {
|
|
if (option.type === "radio") {
|
|
return {
|
|
...option,
|
|
onPress: () => {
|
|
option.onPress();
|
|
onOptionSelect?.(option.value);
|
|
onClose?.();
|
|
},
|
|
};
|
|
}
|
|
if (option.type === "toggle") {
|
|
return {
|
|
...option,
|
|
onToggle: () => {
|
|
option.onToggle();
|
|
onOptionSelect?.(option.value);
|
|
},
|
|
};
|
|
}
|
|
if (option.type === "action") {
|
|
return {
|
|
...option,
|
|
onPress: () => {
|
|
option.onPress();
|
|
onClose?.();
|
|
},
|
|
};
|
|
}
|
|
return option;
|
|
}),
|
|
}));
|
|
|
|
return (
|
|
<BottomSheetScrollView
|
|
className='px-4 pb-8 pt-2'
|
|
style={{
|
|
paddingLeft: Math.max(16, insets.left),
|
|
paddingRight: Math.max(16, insets.right),
|
|
}}
|
|
>
|
|
{title && <Text className='font-bold text-2xl mb-6'>{title}</Text>}
|
|
{wrappedGroups.map((group, index) => (
|
|
<OptionGroupComponent key={index} group={group} />
|
|
))}
|
|
</BottomSheetScrollView>
|
|
);
|
|
};
|
|
|
|
const PlatformDropdownComponent = ({
|
|
trigger,
|
|
title,
|
|
groups,
|
|
open: controlledOpen,
|
|
onOpenChange: controlledOnOpenChange,
|
|
onOptionSelect,
|
|
expoUIConfig,
|
|
bottomSheetConfig,
|
|
}: PlatformDropdownProps) => {
|
|
const { showModal, hideModal, isVisible } = useGlobalModal();
|
|
|
|
// @expo/ui's <Host> (SDK 55) fills its available space by default, and
|
|
// `matchContents` doesn't help here: it reports the native Menu's size via
|
|
// setStyleSize and overrides any explicit size. Instead we measure the
|
|
// trigger's intrinsic size in plain RN (off-layout) and pin it on the Host.
|
|
const [triggerSize, setTriggerSize] = useState<{
|
|
width: number;
|
|
height: number;
|
|
} | null>(null);
|
|
|
|
const handleMeasureTrigger = (e: LayoutChangeEvent) => {
|
|
const { width, height } = e.nativeEvent.layout;
|
|
setTriggerSize((prev) =>
|
|
prev && prev.width === width && prev.height === height
|
|
? prev
|
|
: { width, height },
|
|
);
|
|
};
|
|
|
|
// Handle controlled open state for Android
|
|
useEffect(() => {
|
|
if (Platform.OS === "android" && controlledOpen === true) {
|
|
showModal(
|
|
<BottomSheetContent
|
|
title={title}
|
|
groups={groups}
|
|
onOptionSelect={onOptionSelect}
|
|
onClose={() => {
|
|
hideModal();
|
|
controlledOnOpenChange?.(false);
|
|
}}
|
|
/>,
|
|
{
|
|
snapPoints: ["90%"],
|
|
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
|
},
|
|
);
|
|
}
|
|
}, [controlledOpen]);
|
|
|
|
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
|
// and sync the controlled open state
|
|
useEffect(() => {
|
|
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
|
controlledOnOpenChange?.(false);
|
|
}
|
|
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
|
|
|
if (Platform.OS === "ios") {
|
|
// Pin the wrapper to the measured trigger size. @expo/ui's <Host> (SDK 55)
|
|
// fills its parent and reports its own size via setStyleSize, so it can't
|
|
// size itself to content. If the wrapper has no size, the Host's `flex: 1`
|
|
// height depends on the parent while the parent depends on the Host — a
|
|
// circular dependency that collapses to 0 for any selector nested more than
|
|
// one level deep (so only the first, shallowest dropdown stays visible).
|
|
// Giving the wrapper the measured size breaks the cycle; the Host then
|
|
// fills a concrete box.
|
|
return (
|
|
<View style={triggerSize ?? { opacity: 0 }}>
|
|
{/* Hidden measurer: lays the trigger out off-flow to capture its
|
|
intrinsic size. Absolutely positioned WITHOUT right/bottom so it
|
|
sizes to the trigger's content rather than to its parent. */}
|
|
<View
|
|
style={{ position: "absolute", top: 0, left: 0, opacity: 0 }}
|
|
pointerEvents='none'
|
|
aria-hidden
|
|
onLayout={handleMeasureTrigger}
|
|
>
|
|
{trigger}
|
|
</View>
|
|
<Host style={[StyleSheet.absoluteFill, expoUIConfig?.hostStyle as any]}>
|
|
<Menu label={trigger}>
|
|
{groups.flatMap((group, groupIndex) => {
|
|
// Check if this group has radio options
|
|
const radioOptions = group.options.filter(
|
|
(opt) => opt.type === "radio",
|
|
) as RadioOption[];
|
|
const toggleOptions = group.options.filter(
|
|
(opt) => opt.type === "toggle",
|
|
) as ToggleOption[];
|
|
const actionOptions = group.options.filter(
|
|
(opt) => opt.type === "action",
|
|
) as ActionOption[];
|
|
|
|
const items = [];
|
|
|
|
// Add Picker for radio options ONLY if there's a group title
|
|
// Otherwise render as individual buttons
|
|
if (radioOptions.length > 0) {
|
|
if (group.title) {
|
|
// Use Picker for grouped options.
|
|
// Use the option index (a stable primitive) as the
|
|
// tag/selection value and React key. Option `value`s can be
|
|
// objects (e.g. bitrate / media source), which collapse to
|
|
// "[object Object]" as a key and never match the Picker's
|
|
// primitive selection.
|
|
const selectedRadioIndex = radioOptions.findIndex(
|
|
(opt) => opt.selected,
|
|
);
|
|
items.push(
|
|
<Picker
|
|
key={`picker-${groupIndex}`}
|
|
label={group.title}
|
|
selection={
|
|
selectedRadioIndex >= 0 ? selectedRadioIndex : undefined
|
|
}
|
|
onSelectionChange={(index) => {
|
|
const selectedOption = radioOptions[index as number];
|
|
selectedOption?.onPress();
|
|
onOptionSelect?.(selectedOption?.value);
|
|
}}
|
|
>
|
|
{radioOptions.map((opt, optionIndex) => (
|
|
<SwiftUIText
|
|
key={`radio-${groupIndex}-${optionIndex}`}
|
|
modifiers={[tag(optionIndex)]}
|
|
>
|
|
{opt.label}
|
|
</SwiftUIText>
|
|
))}
|
|
</Picker>,
|
|
);
|
|
} else {
|
|
// Render radio options as direct buttons
|
|
radioOptions.forEach((option, optionIndex) => {
|
|
items.push(
|
|
<Button
|
|
key={`radio-${groupIndex}-${optionIndex}`}
|
|
label={option.label}
|
|
systemImage={
|
|
option.selected ? "checkmark.circle.fill" : "circle"
|
|
}
|
|
modifiers={
|
|
option.disabled ? [disabled(true)] : undefined
|
|
}
|
|
onPress={() => {
|
|
option.onPress();
|
|
onOptionSelect?.(option.value);
|
|
}}
|
|
/>,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add Buttons for toggle options
|
|
toggleOptions.forEach((option, optionIndex) => {
|
|
items.push(
|
|
<Button
|
|
key={`toggle-${groupIndex}-${optionIndex}`}
|
|
label={option.label}
|
|
systemImage={
|
|
option.value ? "checkmark.circle.fill" : "circle"
|
|
}
|
|
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
onPress={() => {
|
|
option.onToggle();
|
|
onOptionSelect?.(option.value);
|
|
}}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
// Add Buttons for action options (no icon)
|
|
actionOptions.forEach((option, optionIndex) => {
|
|
items.push(
|
|
<Button
|
|
key={`action-${groupIndex}-${optionIndex}`}
|
|
label={option.label}
|
|
modifiers={option.disabled ? [disabled(true)] : undefined}
|
|
onPress={() => {
|
|
option.onPress();
|
|
}}
|
|
/>,
|
|
);
|
|
});
|
|
|
|
return items;
|
|
})}
|
|
</Menu>
|
|
</Host>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Android: Direct modal trigger
|
|
const handlePress = () => {
|
|
showModal(
|
|
<BottomSheetContent
|
|
title={title}
|
|
groups={groups}
|
|
onOptionSelect={onOptionSelect}
|
|
onClose={hideModal}
|
|
/>,
|
|
{
|
|
snapPoints: ["90%"],
|
|
enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true,
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
|
|
{trigger || <Text className='text-white'>Open Menu</Text>}
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
// Memoize to prevent unnecessary re-renders when parent re-renders
|
|
export const PlatformDropdown = React.memo(
|
|
PlatformDropdownComponent,
|
|
(prevProps, nextProps) => {
|
|
// Custom comparison - only re-render if these props actually change
|
|
return (
|
|
prevProps.title === nextProps.title &&
|
|
prevProps.open === nextProps.open &&
|
|
prevProps.groups === nextProps.groups && // Reference equality (works because we memoize groups in caller)
|
|
prevProps.trigger === nextProps.trigger // Reference equality
|
|
);
|
|
},
|
|
);
|