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}
-
-
-
-
+ 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 (
+