From fef1e7f1224addf4e71f5672b851bc6948785fd4 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Sun, 31 May 2026 23:53:12 +1000 Subject: [PATCH] feat(video-player): Update in player controls to new UI --- components/PlatformDropdown.tsx | 278 ++---- components/common/dropdownShared.tsx | 142 +++ .../controls/dropdown/DropdownView.tsx | 80 +- .../dropdown/PlayerSettingsPopover.tsx | 930 ++++++++++++++++++ 4 files changed, 1220 insertions(+), 210 deletions(-) create mode 100644 components/common/dropdownShared.tsx create mode 100644 components/video-player/controls/dropdown/PlayerSettingsPopover.tsx diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index aaea71b3f..23cf233c3 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,14 +1,13 @@ 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 React, { useEffect } 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 { useGlobalModal } from "@/providers/GlobalModalProvider"; @@ -16,7 +15,7 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider"; // 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, Host, Menu } = Platform.isTV +const { Button, Menu } = Platform.isTV ? ({} as typeof import("@expo/ui/swift-ui")) : require("@expo/ui/swift-ui"); const { disabled } = Platform.isTV @@ -72,16 +71,6 @@ interface PlatformDropdownProps { }; } -const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => ( - - - -); - const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ option, isLast, @@ -121,28 +110,15 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({ }; const OptionGroupComponent: React.FC<{ group: OptionGroup }> = ({ group }) => ( - - {group.title && ( - - {group.title} - - )} - - {group.options.map((option, index) => ( - - ))} - - + + {group.options.map((option, index) => ( + + ))} + ); const BottomSheetContent: React.FC<{ @@ -217,24 +193,6 @@ const PlatformDropdownComponent = ({ }: PlatformDropdownProps) => { const { showModal, hideModal, isVisible } = useGlobalModal(); - // @expo/ui's (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) { @@ -265,82 +223,42 @@ const PlatformDropdownComponent = ({ }, [isVisible, controlledOpen, controlledOnOpenChange]); if (Platform.OS === "ios" && !Platform.isTV) { - // Pin the wrapper to the measured trigger size. @expo/ui's (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 ( - - {/* 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. */} - - {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[]; + + + {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 = []; + const items = []; - // Group radio options under a submenu ONLY if there's a title - // Otherwise render as individual buttons - if (radioOptions.length > 0) { - if (group.title) { - // Use a nested Menu as a submenu for grouped options. This - // reads as "Title: Selected" and expands to the choices on - // tap, keeping the nested look while staying a dropdown. - // (Menu opens on a single tap and nests cleanly; ContextMenu - // would require a long-press and read as a context menu.) - const selectedOption = radioOptions.find( - (opt) => opt.selected, - ); - const displayTitle = selectedOption - ? `${group.title}: ${selectedOption.label}` - : group.title; - items.push( - - {radioOptions.map((option, optionIndex) => ( - , - ); - } else { - // Render radio options as direct buttons - radioOptions.forEach((option, optionIndex) => { - items.push( + // Group radio options under a submenu ONLY if there's a title + // Otherwise render as individual buttons + if (radioOptions.length > 0) { + if (group.title) { + // Use a nested Menu as a submenu for grouped options. This + // reads as "Title: Selected" and expands to the choices on + // tap, keeping the nested look while staying a dropdown. + // (Menu opens on a single tap and nests cleanly; ContextMenu + // would require a long-press and read as a context menu.) + const selectedOption = radioOptions.find((opt) => opt.selected); + const displayTitle = selectedOption + ? `${group.title}: ${selectedOption.label}` + : group.title; + items.push( + + {radioOptions.map((option, optionIndex) => ( , + ); + } else { + // Render radio options as direct buttons + radioOptions.forEach((option, optionIndex) => { + items.push( + - - + return items; + })} + + ); } diff --git a/components/common/dropdownShared.tsx b/components/common/dropdownShared.tsx new file mode 100644 index 000000000..cf5cd3b27 --- /dev/null +++ b/components/common/dropdownShared.tsx @@ -0,0 +1,142 @@ +// Shared internals for PlatformDropdown and PlayerSettingsPopover. +// Both components host SwiftUI content (Menu / Popover) inside @expo/ui's +// , both render an Android bottom-sheet card for the same three core +// option types (radio / toggle / action), and both wear the same wrapper +// boilerplate. This module is the single source of truth for those pieces. +// +// What lives here: +// - useTriggerSize() — measures the RN trigger's intrinsic size +// - MeasuredTriggerHost — pins to that measured size (workaround +// for @expo/ui SDK 55 sizing behaviour; see notes below) +// - ToggleSwitch — the small purple switch used in the Android sheet +// - OptionGroupCard — the rounded dark card with optional title that +// wraps a group's option rows on Android +// +// What deliberately doesn't live here: +// - The iOS rendering — PlatformDropdown uses a Menu, PlayerSettingsPopover +// uses a hand-styled Popover. Nothing meaningful to share. +// - The Android per-row renderers — PlatformDropdown handles 3 option types, +// PlayerSettingsPopover handles 6 (adds slider/stepper/subgroup). Forcing +// a shared abstraction would couple them. Each owns its own OptionItem. + +import React, { useCallback, useState } from "react"; +import { + type LayoutChangeEvent, + Platform, + StyleSheet, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; + +// @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. Load it lazily and only +// off-TV; both consumers also gate rendering on Platform.OS === "ios". +const { Host } = Platform.isTV + ? ({} as typeof import("@expo/ui/swift-ui")) + : require("@expo/ui/swift-ui"); + +type TriggerSize = { width: number; height: number }; + +/** + * Measures and remembers the intrinsic size of a RN trigger view so the + * surrounding can be pinned to a concrete box. + * + * Returns `[size, handleLayout]` — pass `handleLayout` to a hidden, + * absolutely-positioned mirror of the trigger and use `size` as the + * wrapper's `style` once measured. + */ +export function useTriggerSize(): [ + TriggerSize | null, + (e: LayoutChangeEvent) => void, +] { + const [size, setSize] = useState(null); + const onLayout = useCallback((e: LayoutChangeEvent) => { + const { width, height } = e.nativeEvent.layout; + setSize((prev) => + prev && prev.width === width && prev.height === height + ? prev + : { width, height }, + ); + }, []); + return [size, onLayout]; +} + +interface MeasuredTriggerHostProps { + trigger: React.ReactNode; + hostStyle?: any; + children: React.ReactNode; +} + +/** + * Pins @expo/ui's to the trigger's measured size. + * + * @expo/ui's (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 + * dropdown nested more than one level deep (so only the first, shallowest + * dropdown on screen stays visible). + * + * Giving the wrapper the measured trigger size breaks the cycle; the Host + * then fills a concrete box. + */ +export const MeasuredTriggerHost: React.FC = ({ + trigger, + hostStyle, + children, +}) => { + const [size, handleMeasure] = useTriggerSize(); + return ( + + {/* 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. */} + + {trigger} + + + {children} + + + ); +}; + +/** Small pill switch used by Android sheet rows. */ +export const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => ( + + + +); + +/** + * Rounded dark card with an optional title above it. Wraps a group's option + * rows in the Android bottom sheet. + */ +export const OptionGroupCard: React.FC<{ + title?: string; + children: React.ReactNode; +}> = ({ title, children }) => ( + + {title && ( + + {title} + + )} + + {children} + + +); diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index 7b6713b39..9246ae9da 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -3,10 +3,6 @@ import { useLocalSearchParams } from "expo-router"; import { useCallback, useMemo, useRef } from "react"; import { Platform, View } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; -import { - type OptionGroup, - PlatformDropdown, -} from "@/components/PlatformDropdown"; import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector"; import useRouter from "@/hooks/useAppRouter"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; @@ -14,20 +10,10 @@ import { useSettings } from "@/utils/atoms/settings"; import { usePlayerContext } from "../contexts/PlayerContext"; import { useVideoContext } from "../contexts/VideoContext"; import { PlaybackSpeedScope } from "../utils/playback-speed-settings"; - -// Subtitle scale presets (direct multiplier values) -const SUBTITLE_SCALE_PRESETS = [ - { label: "0.1x", value: 0.1 }, - { label: "0.25x", value: 0.25 }, - { label: "0.5x", value: 0.5 }, - { label: "0.75x", value: 0.75 }, - { label: "1.0x", value: 1.0 }, - { label: "1.25x", value: 1.25 }, - { label: "1.5x", value: 1.5 }, - { label: "2.0x", value: 2.0 }, - { label: "2.5x", value: 2.5 }, - { label: "3.0x", value: 3.0 }, -] as const; +import { + type OptionGroup, + PlayerSettingsPopover, +} from "./PlayerSettingsPopover"; interface DropdownViewProps { playbackSpeed?: number; @@ -102,6 +88,7 @@ const DropdownView = ({ if (!isOffline) { groups.push({ title: "Quality", + icon: "gauge.with.dots.needle.50percent", options: BITRATES?.map((bitrate) => ({ type: "radio" as const, @@ -113,29 +100,41 @@ const DropdownView = ({ }); } - // Subtitle Section + // Subtitles section. iOS: tap the `...` opens a SwiftUI Popover with the + // section header "SUBTITLES" + a Track row (Menu) + a Size row (native + // Slider). Android: same shape in a bottom-sheet — tap the "Track" row to + // expand the list inline, Size shows a Material 3 Slider. if (subtitleTracks && subtitleTracks.length > 0) { groups.push({ title: "Subtitles", - options: subtitleTracks.map((sub) => ({ - type: "radio" as const, - label: sub.name, - value: sub.index.toString(), - selected: subtitleIndex === sub.index.toString(), - onPress: () => sub.setTrack(), - })), - }); - - // Subtitle Scale Section - groups.push({ - title: "Subtitle Scale", - options: SUBTITLE_SCALE_PRESETS.map((preset) => ({ - type: "radio" as const, - label: preset.label, - value: preset.value.toString(), - selected: (settings.mpvSubtitleScale ?? 1.0) === preset.value, - onPress: () => updateSettings({ mpvSubtitleScale: preset.value }), - })), + options: [ + { + type: "subgroup" as const, + label: "Track", + icon: "captions.bubble", + options: subtitleTracks.map((sub) => ({ + type: "radio" as const, + label: sub.name, + value: sub.index.toString(), + selected: subtitleIndex === sub.index.toString(), + onPress: () => sub.setTrack(), + })), + }, + { + type: "slider" as const, + label: "Size", + icon: "textformat.size", + value: Math.round((settings.mpvSubtitleScale ?? 1.0) * 10) / 10, + step: 0.1, + min: 0.1, + max: 3.0, + format: (v: number) => `${v.toFixed(1)}x`, + onValueChange: (value: number) => + updateSettings({ + mpvSubtitleScale: Math.round(value * 10) / 10, + }), + }, + ], }); } @@ -143,6 +142,7 @@ const DropdownView = ({ if (audioTracks && audioTracks.length > 0) { groups.push({ title: "Audio", + icon: "speaker.wave.2", options: audioTracks.map((track) => ({ type: "radio" as const, label: track.name, @@ -157,6 +157,7 @@ const DropdownView = ({ if (setPlaybackSpeed) { groups.push({ title: "Speed", + icon: "speedometer", options: PLAYBACK_SPEEDS.map((speed) => ({ type: "radio" as const, label: speed.label, @@ -176,6 +177,7 @@ const DropdownView = ({ label: showTechnicalInfo ? "Hide Technical Info" : "Show Technical Info", + icon: "info.circle", onPress: onToggleTechnicalInfo, }, ], @@ -216,7 +218,7 @@ const DropdownView = ({ if (Platform.isTV) return null; return ( - = 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, +);