diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx index db223b2bf..69b980d3e 100644 --- a/app/(auth)/(tabs)/(home)/settings.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -59,17 +59,19 @@ function SettingsMobile() { - - - - router.push("/(auth)/(tabs)/(home)/companion-login") - } - title={t("pairing.pair_with_phone")} - textColor='blue' - /> - - + {Platform.OS !== "ios" && ( + + + + router.push("/(auth)/(tabs)/(home)/companion-login") + } + title={t("pairing.pair_with_phone")} + textColor='blue' + /> + + + )} diff --git a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx index 1c4dcd199..8f0a2c931 100644 --- a/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/plugins/streamystats/page.tsx @@ -114,7 +114,7 @@ export default function StreamystatsPage() { }; const handleRefreshFromServer = useCallback(async () => { - const newPluginSettings = await refreshStreamyfinPluginSettings(true); + const newPluginSettings = await refreshStreamyfinPluginSettings(); // Update local state with new values const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || ""; setUrl(newUrl); diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx index acc7f8173..ed31b438f 100644 --- a/app/(auth)/(tabs)/(libraries)/_layout.tsx +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -166,7 +166,7 @@ export default function IndexLayout() { open={dropdownOpen} onOpenChange={setDropdownOpen} trigger={ - + { keyboardDismissMode='none' screenOptions={{ tabBarBounces: true, + tabBarActiveTintColor: "#FFFFFF", + tabBarInactiveTintColor: "#9CA3AF", tabBarLabelStyle: { fontSize: TAB_LABEL_FONT_SIZE, fontWeight: "600", diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index 937c32092..2b269991b 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -274,6 +274,11 @@ export default function DirectPlayerPage() { }; if (itemId) { + setItem(null); + setDownloadedItem(null); + // Clear the previous episode's stream so the loader gate stays closed + // until the new item's stream resolves (avoids a stale MPV source frame). + setStream(null); fetchItemData(); } }, [itemId, offline, api, user?.Id]); @@ -316,6 +321,12 @@ export default function DirectPlayerPage() { return null; } + // Ensure item matches the current itemId to avoid race conditions + if (item.Id !== itemId) { + setStreamStatus({ isLoading: false, isError: false }); + return null; + } + let result: Stream | null = null; if (offline && downloadedItem?.mediaSource) { const url = downloadedItem.videoFilePath; @@ -388,6 +399,7 @@ export default function DirectPlayerPage() { item, user?.Id, downloadedItem, + offline, ]); useEffect(() => { @@ -427,21 +439,15 @@ export default function DirectPlayerPage() { if (!item?.Id || !stream?.sessionId || offline || !api) return; const currentTimeInTicks = msToTicks(progress.get()); - await getPlaystateApi(api).onPlaybackStopped({ - itemId: item.Id, - mediaSourceId: mediaSourceId, - positionTicks: currentTimeInTicks, - playSessionId: stream.sessionId, + await getPlaystateApi(api).reportPlaybackStopped({ + playbackStopInfo: { + ItemId: item.Id, + MediaSourceId: mediaSourceId, + PositionTicks: currentTimeInTicks, + PlaySessionId: stream.sessionId, + }, }); - }, [ - api, - item, - mediaSourceId, - stream, - progress, - offline, - revalidateProgressCache, - ]); + }, [api, item, mediaSourceId, stream, progress, offline]); const stop = useCallback(() => { // Update URL with final playback position before stopping @@ -459,9 +465,10 @@ export default function DirectPlayerPage() { useEffect(() => { const beforeRemoveListener = navigation.addListener("beforeRemove", stop); return () => { + reportPlaybackStopped(); beforeRemoveListener(); }; - }, [navigation, stop]); + }, [navigation, stop, reportPlaybackStopped]); const currentPlayStateInfo = useCallback((): | PlaybackProgressInfo diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index aaea71b3f..5487393dd 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -1,13 +1,7 @@ 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 { Text } from "@/components/common/Text"; import { useGlobalModal } from "@/providers/GlobalModalProvider"; @@ -217,24 +211,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,25 +241,11 @@ 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. + // @expo/ui's can't size to content, so an in-flow invisible copy of + // the trigger sizes the wrapper while the Host overlays the real Menu. 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} diff --git a/components/chapters/ChapterList.tsx b/components/chapters/ChapterList.tsx index dd6ef1bd9..e44332095 100644 --- a/components/chapters/ChapterList.tsx +++ b/components/chapters/ChapterList.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { FlatList, Modal, Pressable, StyleSheet, View } from "react-native"; import { Text } from "@/components/common/Text"; import { Colors } from "@/constants/Colors"; +import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets"; import { type ChapterEntry, chapterStartsMs, @@ -38,6 +39,7 @@ function ChapterListComponent({ onClose, }: ChapterListProps) { const { t } = useTranslation(); + const safeArea = useControlsSafeAreaInsets(); const listRef = useRef>(null); const entries = useMemo(() => sortedChapters(chapters), [chapters]); @@ -79,7 +81,17 @@ function ChapterListComponent({ supportedOrientations={["portrait", "landscape"]} > - e.stopPropagation()} style={styles.sheet}> + e.stopPropagation()} + style={[ + styles.sheet, + { + marginLeft: safeArea.left, + marginRight: safeArea.right, + paddingBottom: safeArea.bottom, + }, + ]} + > {t("chapters.title")} { onPress={() => { router.push("/(auth)/downloads"); }} - className='ml-1.5' style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} > = ({ {/* Pair with Phone */} - {onStartPairing && ( + {Platform.OS !== "ios" && onStartPairing && (