import { Ionicons } from "@expo/vector-icons"; import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import React, { useEffect, useState } from "react"; import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { MeasuredTriggerHost, OptionGroupCard, ToggleSwitch, } from "@/components/common/dropdownShared"; import { Text } from "@/components/common/Text"; import type { ActionOption as BaseActionOption, RadioOption as BaseRadioOption, ToggleOption as BaseToggleOption, } from "@/components/PlatformDropdown"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; // Player-only popover/sheet. Shares no rendering with `PlatformDropdown`: // that component is used by ~20 callers (settings, season pickers, // bitrate/audio/subtitle selectors, …) and must keep its small native // Menu look. This one targets the in-player `...` button and is allowed to // (a) host a real slider, (b) wear the Swift-mock visual style, and // (c) carry SF Symbol icons per row. // // Common boilerplate (trigger measurement, ToggleSwitch, Android option-card // shell) lives in @/components/common/dropdownShared and is reused with // PlatformDropdown. // // @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds. // A static top-level import evaluates requireNativeModule('ExpoUI') at module // load and crashes the entire route tree on tvOS (expo-router requires every // route file). Load it lazily and only off-TV; TV never renders these. const { Button, HStack, Image: SwiftImage, Menu, Popover, Rectangle: SwiftRectangle, Slider: SwiftSlider, Spacer, Stepper, Text: SwiftText, Toggle: SwiftToggle, VStack, } = Platform.isTV ? ({} as typeof import("@expo/ui/swift-ui")) : require("@expo/ui/swift-ui"); const { buttonStyle, disabled, font, foregroundStyle, frame, opacity, padding, tint, } = Platform.isTV ? ({} as typeof import("@expo/ui/swift-ui/modifiers")) : require("@expo/ui/swift-ui/modifiers"); // Android-side Material 3 slider. Lives in @expo/ui/community/slider and is a // drop-in for react-native-community/slider on Android (and SwiftUI Slider on // iOS, but we use the swift-ui Slider directly inside the popover instead). const { Slider: CommunitySlider } = Platform.isTV ? ({} as typeof import("@expo/ui/community/slider")) : require("@expo/ui/community/slider"); // --------------------------------------------------------------------------- // Option model // --------------------------------------------------------------------------- // Reuses PlatformDropdown's three base option types (so the 20+ shared callers // and the player popover stay in sync on shape), then adds: // - `icon?: string` on every variant — SF Symbol shown in the iOS popover // - Slider / Stepper / Subgroup variants for the player's extra controls type WithIcon = { icon?: string }; export type RadioOption = BaseRadioOption & WithIcon; export type ToggleOption = BaseToggleOption & WithIcon; export type ActionOption = BaseActionOption & WithIcon; export type StepperOption = { type: "stepper"; label: string; value: number; step: number; min: number; max: number; onValueChange: (value: number) => void; /** Optional value formatter for the displayed number. */ format?: (value: number) => string; disabled?: boolean; } & WithIcon; export type SliderOption = { type: "slider"; label: string; value: number; step: number; min: number; max: number; onValueChange: (value: number) => void; /** Optional value formatter for the displayed number. */ format?: (value: number) => string; disabled?: boolean; } & WithIcon; /** * A row that itself opens a nested dropdown. On iOS this renders as a * SwiftUI `Menu` inside the popover (label = subgroup name, value = * currently-selected child); on Android the row expands inline to show its * options when tapped (and collapses again on a second tap). */ export type SubgroupOption = { type: "subgroup"; label: string; options: Option[]; disabled?: boolean; } & WithIcon; export type Option = | RadioOption | ToggleOption | ActionOption | StepperOption | SliderOption | SubgroupOption; export type OptionGroup = { title?: string; options: Option[]; /** * Optional SF Symbol used for the group's row in the iOS popover when the * entire group is compressed to a single Menu (e.g. radio-only groups). */ icon?: string; }; interface PlayerSettingsPopoverProps { 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; }; } // --------------------------------------------------------------------------- // Android bottom-sheet renderers // --------------------------------------------------------------------------- const StepperControl: React.FC<{ option: StepperOption; }> = ({ option }) => { const display = option.format ? option.format(option.value) : option.value.toString(); const canDecrement = option.value > option.min; const canIncrement = option.value < option.max; const decrement = () => { if (option.disabled) return; const next = Math.max(option.min, option.value - option.step); if (next !== option.value) option.onValueChange(next); }; const increment = () => { if (option.disabled) return; const next = Math.min(option.max, option.value + option.step); if (next !== option.value) option.onValueChange(next); }; return ( - {display} + ); }; /** * Android: full-width Material 3 slider inside the bottom sheet, with a * label/value row above the track. The slider lives below the touch target so * dragging it doesn't accidentally collapse the sheet. */ const SliderControl: React.FC<{ option: SliderOption; }> = ({ option }) => { const display = option.format ? option.format(option.value) : option.value.toString(); return ( {option.label} {display} ); }; const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ option, isLast, }) => { const [expanded, setExpanded] = useState(false); const isToggle = option.type === "toggle"; const isAction = option.type === "action"; const isStepper = option.type === "stepper"; const isSlider = option.type === "slider"; const isSubgroup = option.type === "subgroup"; if (isSlider) { return ( <> {!isLast && ( )} ); } const handlePress = isToggle ? option.onToggle : isSubgroup ? () => setExpanded((v) => !v) : isStepper ? undefined : (option as RadioOption | ActionOption).onPress; const selectedChild = isSubgroup ? (option.options.find( (o): o is RadioOption => o.type === "radio" && o.selected, ) ?? undefined) : undefined; return ( <> {option.label} {isToggle ? ( ) : isStepper ? ( ) : isSubgroup ? ( {selectedChild && ( {selectedChild.label} )} ) : isAction ? null : (option as RadioOption).selected ? ( ) : ( )} {isSubgroup && expanded && ( {option.options.map((child, childIndex) => ( ))} )} {!isLast && ( )} ); }; const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => ( {group.options.map((option, index) => ( ))} ); const BottomSheetContent: React.FC<{ title?: string; groups: OptionGroup[]; onOptionSelect?: (value?: any) => void; onClose?: () => void; }> = ({ title, groups, onOptionSelect, onClose }) => { const insets = useSafeAreaInsets(); // Recursively wrap options so radio/action presses also call // onOptionSelect/onClose, including options nested inside subgroups. const wrapOption = (option: Option): 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?.(); }, }; } if (option.type === "subgroup") { return { ...option, options: option.options.map(wrapOption) }; } return option; }; const wrappedGroups = groups.map((group) => ({ ...group, options: group.options.map(wrapOption), })); return ( {title && {title}} {wrappedGroups.map((group, index) => ( ))} ); }; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- const PlayerSettingsPopoverComponent = ({ trigger, title, groups, open: controlledOpen, onOpenChange: controlledOnOpenChange, onOptionSelect, expoUIConfig, bottomSheetConfig, }: PlayerSettingsPopoverProps) => { const { showModal, hideModal, isVisible } = useGlobalModal(); // Android: controlled open routes through the global bottom-sheet modal. useEffect(() => { if (Platform.OS === "android" && controlledOpen === true) { showModal( { hideModal(); controlledOnOpenChange?.(false); }} />, { snapPoints: ["90%"], enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true, }, ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlledOpen]); useEffect(() => { if (Platform.OS === "android" && controlledOpen === true && !isVisible) { controlledOnOpenChange?.(false); } }, [isVisible, controlledOpen, controlledOnOpenChange]); // Internal open state for the iOS popover. Synced both ways with // `controlledOpen` when controlled. const [iosOpen, setIosOpen] = useState(false); useEffect(() => { if (Platform.OS === "ios" && controlledOpen !== undefined) { setIosOpen(controlledOpen); } }, [controlledOpen]); const handleIosOpenChange = (value: boolean) => { setIosOpen(value); controlledOnOpenChange?.(value); }; if (Platform.OS === "ios" && !Platform.isTV) { const closePopover = () => handleIosOpenChange(false); // ---- Swift-mock styled popover body ---- // Mirrors the reference Swift `PlayerSettingsViewController` design: // - small-caps section headers with a hairline rule to the trailing edge // - 44pt rows with leading SF Symbol, 15pt title, trailing value + glyph // - real native Slider rows for slider options // Radio-only titled groups (Quality/Audio/Speed) are compressed to a // single Menu row whose label is a styled HStack — tapping opens the // selection menu without changing the panel's height. type IconName = string | undefined; const MENU_CHEVRON = "chevron.up.chevron.down" as const; const TERTIARY = { type: "hierarchical" as const, style: "tertiary" as const, }; const SECONDARY = { type: "hierarchical" as const, style: "secondary" as const, }; /** 24pt-wide leading icon slot. Renders a transparent placeholder when * no icon is set so titles stay aligned across rows. */ const renderIcon = (icon: IconName) => ( ); /** Small-caps section header + thin separator that fills the row width. */ const renderSectionHeader = (sectionTitle: string, key: string) => ( {sectionTitle.toUpperCase()} ); /** Bare hairline used to close out a multi-row titled section. */ const renderDivider = (key: string) => ( ); /** Render menu-safe children (radio/action) inside a SwiftUI Menu. */ const renderMenuChild = (option: Option, key: string): any => { if (option.type === "radio") { return ( ); /** Render one Option as its own row inside a mixed (non-compressed) * section. */ const renderOptionRow = (option: Option, key: string): any => { if (option.type === "slider") return renderSliderRow(option, key); if (option.type === "stepper") return renderStepperRow(option, key); if (option.type === "toggle") return renderToggleRow(option, key); if (option.type === "action") return renderActionRow(option, key); if (option.type === "subgroup") { const selectedChild = option.options.find( (o): o is RadioOption => o.type === "radio" && o.selected, ); return renderMenuRow({ key, icon: option.icon, title: option.label, valueLabel: selectedChild?.label, children: option.options.map((child, idx) => renderMenuChild(child, `${key}-c${idx}`), ), }); } if (option.type === "radio") { return ( ); } return null; }; /** * Render an entire OptionGroup. * - Titled group with only radio (or radio + action) options → * compressed to a single Menu row. * - Titled group containing slider/toggle/stepper/subgroup → * section header + individual rows. * - Untitled group → individual rows, no header. */ const renderGroup = (group: OptionGroup, groupIndex: number): any[] => { if (group.options.length === 0) return []; const onlyMenuSafe = group.options.every( (o) => o.type === "radio" || o.type === "action", ); if (group.title && onlyMenuSafe) { const selectedRadio = group.options.find( (o): o is RadioOption => o.type === "radio" && o.selected, ); return [ renderMenuRow({ key: `group-${groupIndex}`, icon: group.icon, title: group.title, valueLabel: selectedRadio?.label, children: group.options.map((opt, idx) => renderMenuChild(opt, `g${groupIndex}-c${idx}`), ), }), ]; } const rows: any[] = []; if (group.title) { rows.push(renderSectionHeader(group.title, `header-${groupIndex}`)); } group.options.forEach((opt, idx) => { rows.push(renderOptionRow(opt, `g${groupIndex}-o${idx}`)); }); return rows; }; return ( {/* Wrap the RN trigger view in a SwiftUI Button so tap handling is captured at the SwiftUI layer (matches the codebase pattern in SearchTabButtons.tsx). */} {/* Bare VStack — no Form/List chrome — so the panel reads as the Swift mock's floating glass card. The popover itself supplies the material background; we just stack rows inside. Width pinned to ~320pt; height >= 480pt. */} {groups.flatMap((group, groupIndex) => { const rows = renderGroup(group, groupIndex); if (rows.length === 0) return []; // After a multi-row titled section (Subtitles), append a // bare hairline divider so it's clearly separated from // the next group below. const isMultiRow = !!group.title && !group.options.every( (o) => o.type === "radio" || o.type === "action", ); const hasNext = groupIndex < groups.length - 1; return isMultiRow && hasNext ? [...rows, renderDivider(`footer-${groupIndex}`)] : rows; })} ); } // Android: open the bottom sheet directly on press (uncontrolled mode). const handlePress = () => { showModal( , { snapPoints: ["90%"], enablePanDownToClose: bottomSheetConfig?.enablePanDownToClose ?? true, }, ); }; return ( {trigger || Open Menu} ); }; // Memoize to prevent unnecessary re-renders when parent re-renders. export const PlayerSettingsPopover = React.memo( PlayerSettingsPopoverComponent, (prevProps, nextProps) => prevProps.title === nextProps.title && prevProps.open === nextProps.open && prevProps.groups === nextProps.groups && prevProps.trigger === nextProps.trigger, );