Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Kim
e7cb31c685 fix(auth): clear stored user on logout to prevent empty home on relaunch
On logout, only the token was removed from MMKV while the user JSON was left behind. On next app launch, initialUser rehydrated userAtom from the stale storage while apiAtom was correctly null, so useProtectedRoute treated the session as authenticated and the user landed on an empty (auth)/(tabs)/(home) instead of /login.

- Remove "user" from storage in logoutMutation alongside "token".

- Make initialUser defensive: return null when no token is present so the user/api atoms stay consistent and any pre-existing stale state self-corrects on next launch.
2026-06-01 00:53:10 +10:00
31 changed files with 2987 additions and 11933 deletions

View File

@@ -1,3 +1,9 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -18,11 +24,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { OfflineModeProvider } from "@/providers/OfflineModeProvider";
import { queueAtom } from "@/utils/atoms/queue";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { writeToLog } from "@/utils/log";
export default function DownloadsPage() {
@@ -31,7 +32,7 @@ export default function DownloadsPage() {
const [_queue, _setQueue] = useAtom(queueAtom);
const { downloadedItems, deleteFileByType, deleteAllFiles } = useDownload();
const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
@@ -267,13 +268,19 @@ export default function DownloadsPage() {
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
enablePanDownToClose
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetView>
<View className='p-4 space-y-4 mb-4'>

View File

@@ -1,4 +1,11 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router";
@@ -24,12 +31,6 @@ import { ItemActions } from "@/components/series/SeriesActions";
import useRouter from "@/hooks/useAppRouter";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import {
type IssueType,
@@ -75,8 +76,8 @@ const MobilePage: React.FC = () => {
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const [issueTypeDropdownOpen, setIssueTypeDropdownOpen] = useState(false);
const advancedReqModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {
data: details,
@@ -142,6 +143,17 @@ const MobilePage: React.FC = () => {
}
}, [jellyseerrApi, pendingRequest, refetch, t]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
jellyseerrApi
@@ -466,7 +478,6 @@ const MobilePage: React.FC = () => {
// This is till it's fixed because the menu isn't selectable on TV
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
@@ -474,6 +485,8 @@ const MobilePage: React.FC = () => {
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
stackBehavior='push'
onDismiss={handleIssueModalDismiss}
>
<BottomSheetView>

View File

@@ -1,5 +1,6 @@
import { ExpoAvRoutePickerView } from "@douglowder/expo-av-route-picker-view";
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
@@ -221,129 +222,133 @@ export default function NowPlayingScreen() {
if (!currentTrack) {
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
</BottomSheetModalProvider>
);
}
return (
<BottomSheetModalProvider>
<View
className='flex-1 bg-[#121212] items-center justify-center'
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
<Text className='text-neutral-500'>No track playing</Text>
</View>
);
}
return (
<View
className='flex-1 bg-[#121212]'
style={{
paddingTop: Platform.OS === "android" ? insets.top : 0,
paddingBottom: Platform.OS === "android" ? insets.bottom : 0,
}}
>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<View className='flex-row'>
{/* Header */}
<View className='flex-row items-center justify-between px-4 pt-3 pb-2'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
onPress={handleClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
className='p-2'
>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
<Ionicons name='chevron-down' size={28} color='white' />
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
<View className='flex-row'>
<TouchableOpacity
onPress={() => setViewMode("player")}
className='px-3 py-1'
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
<Text
className={
viewMode === "player"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Now Playing
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode("queue")}
className='px-3 py-1'
>
<Text
className={
viewMode === "queue"
? "text-white font-semibold"
: "text-neutral-500"
}
>
Queue ({queue.length})
</Text>
</TouchableOpacity>
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
</View>
{/* Empty placeholder to balance header layout */}
<View className='p-2' style={{ width: 44 }} />
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
{viewMode === "player" ? (
<PlayerView
api={api}
currentTrack={currentTrack}
imageUrl={imageUrl}
sliderProgress={sliderProgress}
sliderMin={sliderMin}
sliderMax={sliderMax}
progressText={progressText}
remainingText={remainingText}
isPlaying={isPlaying}
isLoading={isLoading}
repeatMode={repeatMode}
shuffleEnabled={shuffleEnabled}
canGoNext={canGoNext}
canGoPrevious={canGoPrevious}
onSliderComplete={handleSliderComplete}
onTogglePlayPause={togglePlayPause}
onNext={next}
onPrevious={previous}
onCycleRepeat={cycleRepeatMode}
onToggleShuffle={toggleShuffle}
getRepeatIcon={getRepeatIcon}
mediaSource={mediaSource}
isTranscoding={isTranscoding}
isFavorite={isFavorite}
onToggleFavorite={toggleFavorite}
onOptionsPress={handleOptionsPress}
isCastConnected={isCastConnected}
/>
) : (
<QueueView
api={api}
queue={queue}
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
onReorderQueue={reorderQueue}
/>
)}
<TrackOptionsSheet
open={trackOptionsOpen}
setOpen={setTrackOptionsOpen}
track={currentTrack}
onAddToPlaylist={handleAddToPlaylist}
/>
<PlaylistPickerSheet
open={playlistPickerOpen}
setOpen={setPlaylistPickerOpen}
trackToAdd={currentTrack}
onCreateNew={handleCreateNewPlaylist}
/>
<CreatePlaylistModal
open={createPlaylistOpen}
setOpen={setCreatePlaylistOpen}
initialTrackId={currentTrack?.Id}
/>
</View>
</BottomSheetModalProvider>
);
}

View File

@@ -1,5 +1,6 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import NetInfo from "@react-native-community/netinfo";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { onlineManager, QueryClient } from "@tanstack/react-query";
@@ -411,125 +412,127 @@ function Layout() {
<DownloadProvider>
<MusicPlayerProvider>
<GlobalModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
<BottomSheetModalProvider>
<IntroSheetProvider>
<ThemeProvider value={DarkTheme}>
<SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'>
<Stack.Screen
name='(auth)/(tabs)'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>

13767
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -6,11 +12,6 @@ import { Alert, Platform, TouchableOpacity, View } from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Colors } from "@/constants/Colors";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import {
deleteAccountCredential,
type SavedServer,
@@ -38,7 +39,7 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const isAndroid = Platform.OS === "android";
const snapPoints = useMemo(
@@ -63,6 +64,17 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeleteAccount = async (account: SavedServerAccount) => {
if (!server) return;
@@ -106,16 +118,15 @@ export const AccountsSheet: React.FC<AccountsSheetProps> = ({
);
if (!server) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
>
<BottomSheetView
style={{

View File

@@ -1,4 +1,10 @@
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type {
BaseItemDto,
MediaSourceInfo,
@@ -17,11 +23,6 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl";
import { AudioTrackSelector } from "./AudioTrackSelector";
@@ -89,7 +90,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[user],
);
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
@@ -316,6 +317,17 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const renderButtonContent = () => {
// For single item downloads, show progress if item is being processed
// For multi-item downloads (season/series), show progress only if 2+ items are in progress or queued
@@ -363,8 +375,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}
};
if (Platform.isTV) return null;
return (
<View {...props}>
<RoundButton size={size} onPress={onButtonPress}>
@@ -380,7 +390,10 @@ export const DownloadItems: React.FC<DownloadProps> = ({
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
enablePanDownToClose
enableDismissOnClose
android_keyboardInputMode='adjustResize'
keyboardBehavior='interactive'
keyboardBlurBehavior='restore'
>

View File

@@ -1,7 +1,10 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import { useCallback, useEffect } from "react";
import { Platform } from "react-native";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { BottomSheetModal } from "@/utils/expoUiBottomSheet";
/**
* GlobalModal Component
@@ -9,7 +12,8 @@ import { BottomSheetModal } from "@/utils/expoUiBottomSheet";
* This component renders a global bottom sheet modal that can be controlled
* from anywhere in the app using the useGlobalModal hook.
*
* Place this component at the root level of your app (in _layout.tsx).
* Place this component at the root level of your app (in _layout.tsx)
* after BottomSheetModalProvider.
*/
export const GlobalModal = () => {
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
@@ -29,6 +33,17 @@ export const GlobalModal = () => {
[hideModal],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const defaultOptions = {
enableDynamicSizing: true,
enablePanDownToClose: true,
@@ -43,8 +58,6 @@ export const GlobalModal = () => {
// Merge default options with provided options
const modalOptions = { ...defaultOptions, ...modalState.options };
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={modalRef}
@@ -52,9 +65,12 @@ export const GlobalModal = () => {
? { snapPoints: modalOptions.snapPoints }
: { enableDynamicSizing: modalOptions.enableDynamicSizing })}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={modalOptions.handleIndicatorStyle}
backgroundStyle={modalOptions.backgroundStyle}
enablePanDownToClose={modalOptions.enablePanDownToClose}
enableDismissOnClose
stackBehavior='push'
style={{ zIndex: 1000 }}
>
{modalState.content}

View File

@@ -1,4 +1,10 @@
import { Feather, Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { Image } from "expo-image";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -7,11 +13,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { storage } from "@/utils/mmkv";
export interface IntroSheetRef {
@@ -20,7 +21,7 @@ export interface IntroSheetRef {
}
export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
const bottomSheetRef = useRef<BottomSheetMethods>(null);
const bottomSheetRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
@@ -35,6 +36,17 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
},
}));
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDismiss = useCallback(() => {
bottomSheetRef.current?.dismiss();
}, []);
@@ -44,13 +56,11 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
router.push("/settings");
}, []);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetRef}
enablePanDownToClose
enableDynamicSizing
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: "#171717" }}
handleIndicatorStyle={{ backgroundColor: "#737373" }}
>

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type {
MediaSourceInfo,
MediaStream,
@@ -6,13 +12,8 @@ import type {
import type React from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { formatBitrate } from "@/utils/bitrate";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { Badge } from "./Badge";
import { Text } from "./common/Text";
@@ -21,20 +22,9 @@ interface Props {
}
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
if (Platform.isTV) {
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>
</View>
);
}
return (
<View className='px-4 mt-2 mb-4'>
<Text className='text-lg font-bold mb-4'>{t("item_card.video")}</Text>
@@ -46,27 +36,27 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
</TouchableOpacity>
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
enablePanDownToClose
snapPoints={["80%"]}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
>
<BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4'>
<View className='flex-row items-center justify-between mb-2'>
<Text className='text-lg font-bold'>{t("item_card.video")}</Text>
<TouchableOpacity
onPress={() => bottomSheetModalRef.current?.dismiss()}
hitSlop={12}
>
<Ionicons name='close' size={24} color='white' />
</TouchableOpacity>
</View>
<View className='flex flex-col space-y-2 p-4 mb-4'>
<View>
<Text className='text-lg font-bold mb-4'>
{t("item_card.video")}
</Text>
<View className='flex flex-row space-x-2'>
<VideoStreamInfo source={source} />
</View>

View File

@@ -1,3 +1,9 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,11 +17,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { verifyAccountPIN } from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -42,7 +43,7 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [pinCode, setPinCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
@@ -77,6 +78,17 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const shake = () => {
Animated.sequence([
Animated.timing(shakeAnimation, {
@@ -147,18 +159,18 @@ export const PINEntryModal: React.FC<PINEntryModalProps> = ({
]);
};
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{

View File

@@ -1,15 +1,16 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useHaptic } from "@/hooks/useHaptic";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -28,7 +29,7 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -61,6 +62,17 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
[onClose],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSubmit = async () => {
if (!password) {
setError(t("password.enter_password"));
@@ -81,18 +93,18 @@ export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
}
};
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{

View File

@@ -1,4 +1,5 @@
import { Ionicons } from "@expo/vector-icons";
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import React, { useEffect, useState } from "react";
import {
type LayoutChangeEvent,
@@ -10,7 +11,6 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useGlobalModal } from "@/providers/GlobalModalProvider";
import { BottomSheetScrollView } from "@/utils/expoUiBottomSheet";
// @expo/ui's SwiftUI native module (ExpoUI) does not exist in tvOS builds.
// A static top-level import evaluates requireNativeModule('ExpoUI') at module

View File

@@ -1,5 +1,6 @@
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BottomSheetView } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
@@ -31,7 +32,6 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { BottomSheetView } from "@/utils/expoUiBottomSheet";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";

View File

@@ -39,21 +39,19 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
if (Platform.OS === "ios") {
return (
<Pressable onPress={handlePress} {...(viewProps as any)}>
<BlurView
intensity={50}
tint='systemChromeMaterial'
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</BlurView>
<Pressable
onPress={handlePress}
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
{...(viewProps as any)}
>
{icon ? (
<Ionicons
name={icon}
size={size === "large" ? 22 : 18}
color={color === "white" ? "white" : "#9334E9"}
/>
) : null}
{children ? children : null}
</Pressable>
);
}

View File

@@ -1,14 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { AccountSecurityType } from "@/utils/secureCredentials";
import { Button } from "./Button";
import { Text } from "./common/Text";
@@ -57,7 +58,7 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
}) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
const [pinCode, setPinCode] = useState("");
const [pinError, setPinError] = useState<string | null>(null);
@@ -92,6 +93,17 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
setPinError(null);
};
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleOptionSelect = (type: AccountSecurityType) => {
setSelectedType(type);
setPinCode("");
@@ -123,18 +135,18 @@ export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
return true;
};
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
onChange={handleSheetChanges}
handleIndicatorStyle={{ backgroundColor: "white" }}
backgroundStyle={{ backgroundColor: "#171717" }}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView
style={{

View File

@@ -1,10 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import { isEqual } from "lodash";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -12,11 +17,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button";
import { Input } from "../common/Input";
@@ -75,7 +75,7 @@ export const FilterSheet = <T,>({
disableSearch = false,
multiple = false,
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["85%"], []);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -143,15 +143,24 @@ export const FilterSheet = <T,>({
return data;
}, [search, filteredData, data]);
if (Platform.isTV) return null;
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,3 +1,4 @@
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import React, { useCallback, useImperativeHandle, useRef } from "react";
import {
type StyleProp,
@@ -7,7 +8,6 @@ import {
View,
type ViewStyle,
} from "react-native";
import { BottomSheetTextInput } from "@/utils/expoUiBottomSheet";
interface PinInputProps
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {

View File

@@ -1,22 +1,23 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BottomSheetModalMethods } from "@gorhom/bottom-sheet/lib/typescript/types";
import { useQuery } from "@tanstack/react-query";
import { forwardRef, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, View, type ViewProps } from "react-native";
import { View, type ViewProps } from "react-native";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { PlatformDropdown } from "@/components/PlatformDropdown";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type {
QualityProfile,
RootFolder,
Tag,
} from "@/utils/jellyseerr/server/api/servarr/base";
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { writeDebugLog } from "@/utils/log";
@@ -33,7 +34,7 @@ interface Props {
}
const RequestModal = forwardRef<
BottomSheetMethods,
BottomSheetModalMethods,
Props & Omit<ViewProps, "id">
>(
(
@@ -282,13 +283,11 @@ const RequestModal = forwardRef<
defaultTags,
]);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={ref}
enablePanDownToClose
enableDynamicSizing
enableDismissOnClose
onDismiss={handleDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
@@ -296,6 +295,14 @@ const RequestModal = forwardRef<
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={(sheetProps: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...sheetProps}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
stackBehavior='push'
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>

View File

@@ -1,3 +1,10 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, {
useCallback,
useEffect,
@@ -6,17 +13,11 @@ import React, {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, Keyboard, Platform } from "react-native";
import { ActivityIndicator, Keyboard } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { useCreatePlaylist } from "@/hooks/usePlaylistMutations";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetTextInput,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface Props {
open: boolean;
@@ -31,7 +32,7 @@ export const CreatePlaylistModal: React.FC<Props> = ({
onPlaylistCreated,
initialTrackId,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const createPlaylist = useCreatePlaylist();
@@ -58,6 +59,17 @@ export const CreatePlaylistModal: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
@@ -74,15 +86,13 @@ export const CreatePlaylistModal: React.FC<Props> = ({
const isValid = name.trim().length > 0;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,23 +1,18 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
Platform,
StyleSheet,
TouchableOpacity,
View,
} from "react-native";
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface Props {
open: boolean;
@@ -30,7 +25,7 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
setOpen,
playlist,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const router = useRouter();
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -52,6 +47,17 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleDeletePlaylist = useCallback(() => {
if (!playlist?.Id) return;
@@ -83,15 +89,14 @@ export const PlaylistOptionsSheet: React.FC<Props> = ({
}, [playlist, deletePlaylist, setOpen, router, t]);
if (!playlist) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetScrollView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
@@ -14,7 +20,6 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -24,11 +29,6 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetScrollView,
} from "@/utils/expoUiBottomSheet";
interface Props {
open: boolean;
@@ -43,7 +43,7 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
trackToAdd,
onCreateNew,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
@@ -101,6 +101,17 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSelectPlaylist = useCallback(
async (playlist: BaseItemDto) => {
if (!trackToAdd?.Id || !playlist.Id) return;
@@ -131,15 +142,13 @@ export const PlaylistPickerSheet: React.FC<Props> = ({
[api],
);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,14 +1,15 @@
import { Ionicons } from "@expo/vector-icons";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
type BottomSheetMethods,
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
} from "@gorhom/bottom-sheet";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
export type PlaylistSortOption = "SortName" | "DateCreated";
@@ -42,7 +43,7 @@ export const PlaylistSortSheet: React.FC<Props> = ({
sortOrder,
onSortChange,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -62,6 +63,17 @@ export const PlaylistSortSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handleSortSelect = useCallback(
(option: PlaylistSortOption) => {
// If selecting same option, toggle order; otherwise use sensible default
@@ -81,15 +93,13 @@ export const PlaylistSortSheet: React.FC<Props> = ({
[sortBy, sortOrder, onSortChange, setOpen],
);
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
@@ -12,7 +18,6 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -31,11 +36,6 @@ import {
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
@@ -56,7 +56,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
playlistId,
onRemoveFromPlaylist,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
@@ -128,6 +128,17 @@ export const TrackOptionsSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const handlePlayNext = useCallback(() => {
if (track) {
playNext(track);
@@ -216,14 +227,13 @@ export const TrackOptionsSheet: React.FC<Props> = ({
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
if (!track) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -1,9 +1,14 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type React from "react";
import { useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -11,11 +16,6 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
interface LibraryOptions {
display: "row" | "list";
@@ -132,7 +132,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
updateSettings,
disabled = false,
}) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -161,14 +161,25 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
[setOpen],
);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
if (disabled) return null;
if (Platform.isTV) return null;
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
@@ -176,6 +187,7 @@ export const LibraryOptionsSheet: React.FC<Props> = ({
backgroundColor: "#171717",
}}
enablePanDownToClose
enableDismissOnClose
>
<BottomSheetView>
<View

View File

@@ -1,3 +1,9 @@
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import type React from "react";
@@ -6,11 +12,6 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput";
@@ -24,7 +25,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [quickConnectCode, setQuickConnectCode] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const successHapticFeedback = useHaptic("success");
const errorHapticFeedback = useHaptic("error");
const snapPoints = useMemo(
@@ -35,6 +36,17 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
const { t } = useTranslation();
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
const authorizeQuickConnect = useCallback(async () => {
if (quickConnectCode) {
try {
@@ -85,7 +97,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
snapPoints={snapPoints}
handleIndicatorStyle={{
backgroundColor: "white",
@@ -93,8 +104,11 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
backgroundStyle={{
backgroundColor: "#171717",
}}
backdropComponent={renderBackdrop}
keyboardBehavior={isAndroid ? "fillParent" : "interactive"}
keyboardBlurBehavior='restore'
android_keyboardInputMode='adjustResize'
topInset={isAndroid ? 0 : undefined}
>
<BottomSheetView>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>

View File

@@ -1,4 +1,10 @@
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, {
forwardRef,
@@ -10,7 +16,6 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
StyleSheet,
TouchableOpacity,
View,
@@ -26,11 +31,6 @@ import {
useItemInWatchlists,
useMyWatchlistsQuery,
} from "@/hooks/useWatchlists";
import {
type BottomSheetMethods,
BottomSheetModal,
BottomSheetView,
} from "@/utils/expoUiBottomSheet";
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
export interface WatchlistSheetRef {
@@ -263,7 +263,7 @@ const WatchlistSheetContent: React.FC<WatchlistSheetContentProps> = ({
export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
(_props, ref) => {
const bottomSheetModalRef = useRef<BottomSheetMethods>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [currentItem, setCurrentItem] = React.useState<BaseItemDto | null>(
null,
);
@@ -283,13 +283,23 @@ export const WatchlistSheet = forwardRef<WatchlistSheetRef, object>(
bottomSheetModalRef.current?.dismiss();
}, []);
if (Platform.isTV) return null;
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[],
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
enablePanDownToClose
enableDynamicSizing
maxDynamicContentSize={600}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}

View File

@@ -32,6 +32,7 @@
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/ui": "~56.0.14",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "5.2.14",
"@jellyfin/sdk": "^0.13.0",
"@react-native-community/netinfo": "^12.0.0",
"@react-navigation/material-top-tabs": "7.4.28",

View File

@@ -1,3 +1,4 @@
import type { BottomSheetModal } from "@gorhom/bottom-sheet";
import type React from "react";
import {
createContext,
@@ -10,7 +11,6 @@ import {
} from "react";
import { BackHandler, Platform } from "react-native";
import type { BottomSheetMethods } from "@/utils/expoUiBottomSheet";
interface ModalOptions {
enableDynamicSizing?: boolean;
@@ -30,7 +30,7 @@ interface GlobalModalContextType {
hideModal: () => void;
isVisible: boolean;
modalState: GlobalModalState;
modalRef: React.RefObject<BottomSheetMethods | null>;
modalRef: React.RefObject<BottomSheetModal | null>;
}
const GlobalModalContext = createContext<GlobalModalContextType | undefined>(
@@ -57,7 +57,7 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
options: undefined,
});
const [isVisible, setIsVisible] = useState(false);
const modalRef = useRef<BottomSheetMethods>(null);
const modalRef = useRef<BottomSheetModal>(null);
const showModal = useCallback(
(content: ReactNode, options?: ModalOptions) => {

View File

@@ -69,6 +69,13 @@ const initialApi = (() => {
const initialUser = (() => {
try {
// Only return a stored user if we also have a token. Otherwise the
// user atom would be populated while the api atom is null (e.g. after
// a logout that left stale user JSON in storage), which causes
// useProtectedRoute to keep us inside the (auth) group instead of
// redirecting to /login.
const token = storage.getString("token");
if (!token) return null;
const userStr = storage.getString("user");
if (userStr) {
return JSON.parse(userStr) as UserDto;
@@ -402,6 +409,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
);
storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);

View File

@@ -1,40 +0,0 @@
import { Platform } from "react-native";
/**
* TV-safe re-exports of `@expo/ui/community/bottom-sheet`.
*
* `@expo/ui` resolves its SwiftUI bridge at module load via
* `requireNativeModule('ExpoUI')`. That native module does not exist on tvOS,
* so a static top-level import from any route file crashes the whole route
* tree (expo-router eagerly loads every route).
*
* We `require()` the module lazily and only when *not* on tvOS. On TV the
* exported components are `undefined`, which is fine because every call site
* must early-return (`if (Platform.isTV) return null;`) before rendering a
* bottom sheet.
*
* Usage:
*
* import {
* BottomSheetModal,
* BottomSheetView,
* type BottomSheetMethods,
* } from "@/utils/expoUiBottomSheet";
*
* const ref = useRef<BottomSheetMethods>(null);
*
* if (Platform.isTV) return null;
* return <BottomSheetModal ref={ref}>...</BottomSheetModal>;
*/
type BottomSheetMod = typeof import("@expo/ui/community/bottom-sheet");
const mod: BottomSheetMod = Platform.isTV
? ({} as BottomSheetMod)
: (require("@expo/ui/community/bottom-sheet") as BottomSheetMod);
export const BottomSheetModal = mod.BottomSheetModal;
export const BottomSheetView = mod.BottomSheetView;
export const BottomSheetScrollView = mod.BottomSheetScrollView;
export const BottomSheetTextInput = mod.BottomSheetTextInput;
export type { BottomSheetMethods } from "@expo/ui/community/bottom-sheet";