mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-27 17:18:29 +01:00
- Measure the trigger's intrinsic size in RN and pin it on the @expo/ui Host; SDK 55 Host fills available space by default and matchContents reports the native Menu's size, so neither sized the dropdown correctly. - Swap ContextMenu (long-press) for Menu (tap-to-open). - Render native Button/Picker items via the string `label` prop / SwiftUIText instead of RN <Text> children, which rendered invisibly inside SwiftUI. - Key/tag Picker options by index to avoid duplicate "[object Object]" keys from object-valued options (bitrate, media source).
433 lines
13 KiB
TypeScript
433 lines
13 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") {
|
|
return (
|
|
<View>
|
|
{/* Hidden measurer: lays the trigger out normally to capture its
|
|
intrinsic size, which we then pin onto the Host below. */}
|
|
<View style={StyleSheet.absoluteFill} pointerEvents='none' aria-hidden>
|
|
<View
|
|
style={{ alignSelf: "flex-start" }}
|
|
onLayout={handleMeasureTrigger}
|
|
>
|
|
{trigger}
|
|
</View>
|
|
</View>
|
|
<Host
|
|
style={[
|
|
triggerSize ?? { opacity: 0 },
|
|
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
|
|
);
|
|
},
|
|
);
|