diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 06ee57cc..61461146 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,6 +1,6 @@ import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui"; import { Ionicons } from "@expo/vector-icons"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; @@ -171,7 +171,7 @@ const BottomSheetContent: React.FC<{ ); }; -export function PlatformDropdown({ +const PlatformDropdownComponent = ({ trigger, title, groups, @@ -180,7 +180,7 @@ export function PlatformDropdown({ onOptionSelect, expoUIConfig, bottomSheetConfig, -}: PlatformDropdownProps) { +}: PlatformDropdownProps) => { const { showModal, hideModal } = useGlobalModal(); const handlePress = () => { @@ -208,8 +208,6 @@ export function PlatformDropdown({ }, [open, hideModal]); if (Platform.OS === "ios") { - console.log("[PlatformDropdown iOS] Rendering with groups:", groups.length); - return ( @@ -228,10 +226,6 @@ export function PlatformDropdown({ (opt) => opt.type === "toggle", ) as ToggleOption[]; - console.log( - `[PlatformDropdown iOS] Group ${groupIndex}: ${radioOptions.length} radio, ${toggleOptions.length} toggle`, - ); - const items = []; // Add Picker for radio options if present @@ -246,10 +240,6 @@ export function PlatformDropdown({ (opt) => opt.selected, )} onOptionSelected={(event: any) => { - console.log( - "[PlatformDropdown iOS] Picker option selected:", - event.nativeEvent.index, - ); const index = event.nativeEvent.index; const selectedOption = radioOptions[index]; selectedOption?.onPress(); @@ -268,10 +258,6 @@ export function PlatformDropdown({ option.value ? "checkmark.circle.fill" : "circle" } onPress={() => { - console.log( - "[PlatformDropdown iOS] Toggle pressed:", - option.label, - ); option.onToggle(); onOptionSelect?.(option.value); }} @@ -296,4 +282,18 @@ export function PlatformDropdown({ {trigger || Open Menu} ); -} +}; + +// 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 + ); + }, +); diff --git a/components/video-player/controls/HeaderControls.tsx b/components/video-player/controls/HeaderControls.tsx index 07232014..d8e4dcfe 100644 --- a/components/video-player/controls/HeaderControls.tsx +++ b/components/video-player/controls/HeaderControls.tsx @@ -111,7 +111,7 @@ export const HeaderControls: FC = ({ pointerEvents={showControls ? "auto" : "none"} className={"flex flex-row w-full pt-2"} > - + {!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && ( = ({ setSubtitleTrack={setSubtitleTrack} setSubtitleURL={setSubtitleURL} > - + + + )} diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index c6c8f82f..56dfc23f 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -1,16 +1,12 @@ import { Ionicons } from "@expo/vector-icons"; -import { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Platform, StyleSheet, TouchableOpacity, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useCallback, useMemo, useRef } from "react"; +import { Platform, TouchableOpacity } from "react-native"; import { BITRATES } from "@/components/BitrateSelector"; -import { Text } from "@/components/common/Text"; +import { + type OptionGroup, + PlatformDropdown, +} from "@/components/PlatformDropdown"; import { useControlContext } from "../contexts/ControlContext"; import { useVideoContext } from "../contexts/VideoContext"; @@ -23,10 +19,6 @@ const DropdownView = () => { ControlContext?.mediaSource, ]; const router = useRouter(); - const insets = useSafeAreaInsets(); - const [open, setOpen] = useState(false); - const bottomSheetModalRef = useRef(null); - const snapPoints = useMemo(() => ["75%"], []); const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } = useLocalSearchParams<{ @@ -39,248 +31,127 @@ const DropdownView = () => { offline: string; }>(); + // Use ref to track playbackPosition without causing re-renders + const playbackPositionRef = useRef(playbackPosition); + playbackPositionRef.current = playbackPosition; + const isOffline = offline === "true"; + // Stabilize IDs to prevent unnecessary recalculations + const itemIdRef = useRef(item.Id); + const mediaSourceIdRef = useRef(mediaSource?.Id); + itemIdRef.current = item.Id; + mediaSourceIdRef.current = mediaSource?.Id; + const changeBitrate = useCallback( (bitrate: string) => { const queryParams = new URLSearchParams({ - itemId: item.Id ?? "", + itemId: itemIdRef.current ?? "", audioIndex: audioIndex?.toString() ?? "", - subtitleIndex: subtitleIndex.toString() ?? "", - mediaSourceId: mediaSource?.Id ?? "", + subtitleIndex: subtitleIndex?.toString() ?? "", + mediaSourceId: mediaSourceIdRef.current ?? "", bitrateValue: bitrate.toString(), - playbackPosition: playbackPosition, + playbackPosition: playbackPositionRef.current, }).toString(); router.replace(`player/direct-player?${queryParams}` as any); }, - [item, mediaSource, subtitleIndex, audioIndex, playbackPosition], + [audioIndex, subtitleIndex, router], ); - const handleSheetChanges = useCallback((index: number) => { - if (index === -1) { - setOpen(false); - } - }, []); + // Create stable identifiers for tracks + const subtitleTracksKey = useMemo( + () => subtitleTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "", + [subtitleTracks], + ); - const renderBackdrop = useCallback( - (props: BottomSheetBackdropProps) => ( - + const audioTracksKey = useMemo( + () => audioTracks?.map((t) => `${t.index}-${t.name}`).join(",") ?? "", + [audioTracks], + ); + + // Transform sections into OptionGroup format + const optionGroups = useMemo(() => { + const groups: OptionGroup[] = []; + + // Quality Section + if (!isOffline) { + groups.push({ + title: "Quality", + options: + BITRATES?.map((bitrate) => ({ + type: "radio" as const, + label: bitrate.key, + value: bitrate.value?.toString() ?? "", + selected: bitrateValue === (bitrate.value?.toString() ?? ""), + onPress: () => changeBitrate(bitrate.value?.toString() ?? ""), + })) || [], + }); + } + + // Subtitle Section + 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(), + })), + }); + } + + // Audio Section + if (audioTracks && audioTracks.length > 0) { + groups.push({ + title: "Audio", + options: audioTracks.map((track) => ({ + type: "radio" as const, + label: track.name, + value: track.index.toString(), + selected: audioIndex === track.index.toString(), + onPress: () => track.setTrack(), + })), + }); + } + + return groups; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isOffline, + bitrateValue, + changeBitrate, + subtitleTracksKey, + audioTracksKey, + subtitleIndex, + audioIndex, + // Note: subtitleTracks and audioTracks are intentionally excluded + // because we use subtitleTracksKey and audioTracksKey for stability + ]); + + // Memoize the trigger to prevent re-renders + const trigger = useMemo( + () => ( + + + ), [], ); - const handleOpen = () => { - setOpen(true); - bottomSheetModalRef.current?.present(); - }; - - const handleClose = () => { - setOpen(false); - bottomSheetModalRef.current?.dismiss(); - }; - - useEffect(() => { - if (open) bottomSheetModalRef.current?.present(); - else bottomSheetModalRef.current?.dismiss(); - }, [open]); - // Hide on TV platforms if (Platform.isTV) return null; return ( - <> - - - - - - - - Playback Options - - {/* Quality Section */} - {!isOffline && ( - - - Quality - - - {BITRATES?.map((bitrate, idx: number) => ( - - { - changeBitrate(bitrate.value?.toString() ?? ""); - setTimeout(() => handleClose(), 250); - }} - className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {bitrate.key} - {bitrateValue === (bitrate.value?.toString() ?? "") ? ( - - ) : ( - - )} - - {idx < BITRATES.length - 1 && ( - - )} - - ))} - - - )} - - {/* Subtitle Section */} - - - Subtitles - - - {subtitleTracks?.map((sub, idx: number) => ( - - { - sub.setTrack(); - setTimeout(() => handleClose(), 250); - }} - className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {sub.name} - {subtitleIndex === sub.index.toString() ? ( - - ) : ( - - )} - - {idx < (subtitleTracks?.length ?? 0) - 1 && ( - - )} - - ))} - - - - {/* Audio Section */} - {(audioTracks?.length ?? 0) > 0 && ( - - - Audio - - - {audioTracks?.map((track, idx: number) => ( - - { - track.setTrack(); - setTimeout(() => handleClose(), 250); - }} - className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' - > - {track.name} - {audioIndex === track.index.toString() ? ( - - ) : ( - - )} - - {idx < (audioTracks?.length ?? 0) - 1 && ( - - )} - - ))} - - - )} - - - - + ); };