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 && (