mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Compare commits
9 Commits
feat/andro
...
feat/hide-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b127df39a7 | ||
|
|
3c57829360 | ||
|
|
06349a4319 | ||
|
|
55ac9ae9d4 | ||
|
|
c8bdcc4df0 | ||
|
|
e7013edd84 | ||
|
|
991b45de06 | ||
|
|
97fe899cb0 | ||
|
|
86d7642dca |
@@ -1,5 +1,6 @@
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ControlsSettings } from "@/components/settings/ControlsSettings";
|
||||
import { GestureControls } from "@/components/settings/GestureControls";
|
||||
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||
@@ -25,6 +26,7 @@ export default function PlaybackControlsPage() {
|
||||
<MediaProvider>
|
||||
<MediaToggles className='mb-4' />
|
||||
<GestureControls className='mb-4' />
|
||||
<ControlsSettings className='mb-4' />
|
||||
<PlaybackControlsSettings />
|
||||
</MediaProvider>
|
||||
</View>
|
||||
|
||||
@@ -27,6 +27,9 @@ const Page: React.FC = () => {
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
// preload media sources in background
|
||||
useItemQuery(id, false, undefined, []);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { toast } from "sonner-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { GenreTags } from "@/components/GenreTags";
|
||||
@@ -33,8 +34,16 @@ import {
|
||||
type IssueType,
|
||||
IssueTypeName,
|
||||
} from "@/utils/jellyseerr/server/constants/issue";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import {
|
||||
hasPermission,
|
||||
Permission,
|
||||
} from "@/utils/jellyseerr/server/lib/permissions";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
MovieResult,
|
||||
@@ -58,7 +67,7 @@ const Page: React.FC = () => {
|
||||
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
|
||||
const [issueType, setIssueType] = useState<IssueType>();
|
||||
const [issueMessage, setIssueMessage] = useState<string>();
|
||||
@@ -91,6 +100,46 @@ const Page: React.FC = () => {
|
||||
const [canRequest, hasAdvancedRequestPermission] =
|
||||
useJellyseerrCanRequest(details);
|
||||
|
||||
const canManageRequests = useMemo(() => {
|
||||
if (!jellyseerrUser) return false;
|
||||
return hasPermission(
|
||||
Permission.MANAGE_REQUESTS,
|
||||
jellyseerrUser.permissions,
|
||||
);
|
||||
}, [jellyseerrUser]);
|
||||
|
||||
const pendingRequest = useMemo(() => {
|
||||
return details?.mediaInfo?.requests?.find(
|
||||
(r: MediaRequest) => r.status === MediaRequestStatus.PENDING,
|
||||
);
|
||||
}, [details]);
|
||||
|
||||
const handleApproveRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await jellyseerrApi?.approveRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_approved"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("jellyseerr.toasts.failed_to_approve_request"));
|
||||
console.error("Failed to approve request:", error);
|
||||
}
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const handleDeclineRequest = useCallback(async () => {
|
||||
if (!pendingRequest?.id) return;
|
||||
|
||||
try {
|
||||
await jellyseerrApi?.declineRequest(pendingRequest.id);
|
||||
toast.success(t("jellyseerr.toasts.request_declined"));
|
||||
refetch();
|
||||
} catch (error) {
|
||||
toast.error(t("jellyseerr.toasts.failed_to_decline_request"));
|
||||
console.error("Failed to decline request:", error);
|
||||
}
|
||||
}, [jellyseerrApi, pendingRequest, refetch, t]);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
@@ -334,6 +383,60 @@ const Page: React.FC = () => {
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
{canManageRequests && pendingRequest && (
|
||||
<View className='flex flex-col space-y-2 mt-4'>
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<Ionicons name='person-outline' size={16} color='#9CA3AF' />
|
||||
<Text className='text-sm text-neutral-400'>
|
||||
{t("jellyseerr.requested_by", {
|
||||
user:
|
||||
pendingRequest.requestedBy?.displayName ||
|
||||
pendingRequest.requestedBy?.username ||
|
||||
pendingRequest.requestedBy?.jellyfinUsername ||
|
||||
t("jellyseerr.unknown_user"),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='flex flex-row space-x-2'>
|
||||
<Button
|
||||
className='flex-1 bg-green-600/50 border-green-400 ring-green-400 text-green-100'
|
||||
color='transparent'
|
||||
onPress={handleApproveRequest}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='checkmark-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("jellyseerr.approve")}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
className='flex-1 bg-red-600/50 border-red-400 ring-red-400 text-red-100'
|
||||
color='transparent'
|
||||
onPress={handleDeclineRequest}
|
||||
iconLeft={
|
||||
<Ionicons
|
||||
name='close-outline'
|
||||
size={20}
|
||||
color='white'
|
||||
/>
|
||||
}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<Text className='text-sm'>{t("jellyseerr.decline")}</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<OverviewText text={result.overview} className='mt-4' />
|
||||
</View>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type React from "react";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -18,6 +17,58 @@ import {
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { Loader } from "./Loader";
|
||||
|
||||
const getColorClasses = (
|
||||
color: "purple" | "red" | "black" | "transparent" | "white",
|
||||
variant: "solid" | "border",
|
||||
focused: boolean,
|
||||
): string => {
|
||||
if (variant === "border") {
|
||||
switch (color) {
|
||||
case "purple":
|
||||
return focused
|
||||
? "bg-transparent border-2 border-purple-400"
|
||||
: "bg-transparent border-2 border-purple-600";
|
||||
case "red":
|
||||
return focused
|
||||
? "bg-transparent border-2 border-red-400"
|
||||
: "bg-transparent border-2 border-red-600";
|
||||
case "black":
|
||||
return focused
|
||||
? "bg-transparent border-2 border-neutral-700"
|
||||
: "bg-transparent border-2 border-neutral-900";
|
||||
case "white":
|
||||
return focused
|
||||
? "bg-transparent border-2 border-gray-100"
|
||||
: "bg-transparent border-2 border-white";
|
||||
case "transparent":
|
||||
return focused
|
||||
? "bg-transparent border-2 border-gray-400"
|
||||
: "bg-transparent border-2 border-gray-600";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
} else {
|
||||
switch (color) {
|
||||
case "purple":
|
||||
return focused
|
||||
? "bg-purple-500 border-2 border-white"
|
||||
: "bg-purple-600 border border-purple-700";
|
||||
case "red":
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
return "bg-neutral-900";
|
||||
case "white":
|
||||
return focused
|
||||
? "bg-gray-100 border-2 border-gray-300"
|
||||
: "bg-white border border-gray-200";
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
onPress?: () => void;
|
||||
@@ -26,7 +77,8 @@ export interface ButtonProps
|
||||
disabled?: boolean;
|
||||
children?: string | ReactNode;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black" | "transparent";
|
||||
color?: "purple" | "red" | "black" | "transparent" | "white";
|
||||
variant?: "solid" | "border";
|
||||
iconRight?: ReactNode;
|
||||
iconLeft?: ReactNode;
|
||||
justify?: "center" | "between";
|
||||
@@ -39,6 +91,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
color = "purple",
|
||||
variant = "solid",
|
||||
iconRight,
|
||||
iconLeft,
|
||||
children,
|
||||
@@ -56,23 +109,13 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
const colorClasses = useMemo(() => {
|
||||
switch (color) {
|
||||
case "purple":
|
||||
return focused
|
||||
? "bg-purple-500 border-2 border-white"
|
||||
: "bg-purple-600 border border-purple-700";
|
||||
case "red":
|
||||
return "bg-red-600";
|
||||
case "black":
|
||||
return "bg-neutral-900";
|
||||
case "transparent":
|
||||
return "bg-transparent";
|
||||
}
|
||||
}, [color, focused]);
|
||||
const colorClasses = getColorClasses(color, variant, focused);
|
||||
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const textColorClass =
|
||||
color === "white" && variant === "solid" ? "text-black" : "text-white";
|
||||
|
||||
return Platform.isTV ? (
|
||||
<Pressable
|
||||
className='w-full'
|
||||
@@ -98,10 +141,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
>
|
||||
<View
|
||||
className={`rounded-2xl py-5 items-center justify-center
|
||||
${focused ? "bg-purple-500 border-2 border-white" : "bg-purple-600 border border-purple-700"}
|
||||
${colorClasses}
|
||||
${className}`}
|
||||
>
|
||||
<Text className='text-white text-xl font-bold'>{children}</Text>
|
||||
<Text className={`${textColorClass} text-xl font-bold`}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
@@ -135,7 +180,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
||||
<Text
|
||||
className={`
|
||||
text-white font-bold text-base
|
||||
${textColorClass} font-bold text-base
|
||||
${disabled ? "text-gray-300" : ""}
|
||||
${textClassName}
|
||||
${iconRight ? "mr-2" : ""}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Image } from "expo-image";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
@@ -24,7 +23,6 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -32,6 +30,7 @@ import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
@@ -57,7 +56,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const itemColors = useImageColorsReturn({ item });
|
||||
|
||||
@@ -68,9 +66,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// preload media sources
|
||||
useItemQuery(item.Id, false, undefined, []);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
@@ -231,6 +226,12 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
selectedOptions.mediaSource?.MediaStreams &&
|
||||
selectedOptions.mediaSource.MediaStreams.length > 0 && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
PlayServicesState,
|
||||
@@ -24,6 +25,8 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -33,6 +36,8 @@ import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecast } from "@/utils/profiles/chromecast";
|
||||
import { chromecasth265 } from "@/utils/profiles/chromecasth265";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
@@ -55,6 +60,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
const { t } = useTranslation();
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
|
||||
const [globalColorAtom] = useAtom(itemThemeColorAtom);
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -84,12 +90,9 @@ export const PlayButton: React.FC<Props> = ({
|
||||
[router, isOffline],
|
||||
);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
const handleNormalPlayFlow = useCallback(async () => {
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
|
||||
@@ -271,6 +274,118 @@ export const PlayButton: React.FC<Props> = ({
|
||||
showActionSheetWithOptions,
|
||||
mediaStatus,
|
||||
selectedOptions,
|
||||
goToPlayer,
|
||||
isOffline,
|
||||
t,
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
if (downloadedItem) {
|
||||
if (Platform.OS === "android") {
|
||||
// Show bottom sheet for Android
|
||||
showModal(
|
||||
<BottomSheetView>
|
||||
<View className='px-4 mt-4 mb-12'>
|
||||
<View className='pb-6'>
|
||||
<Text className='text-2xl font-bold mb-2'>
|
||||
{t("player.downloaded_file_title")}
|
||||
</Text>
|
||||
<Text className='opacity-70 text-base'>
|
||||
{t("player.downloaded_file_message")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='space-y-3'>
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideModal();
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
}}
|
||||
color='purple'
|
||||
>
|
||||
{Platform.OS === "android"
|
||||
? "Play downloaded file"
|
||||
: t("player.downloaded_file_yes")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
hideModal();
|
||||
handleNormalPlayFlow();
|
||||
}}
|
||||
color='white'
|
||||
variant='border'
|
||||
>
|
||||
{Platform.OS === "android"
|
||||
? "Stream file"
|
||||
: t("player.downloaded_file_no")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>,
|
||||
{
|
||||
snapPoints: ["35%"],
|
||||
enablePanDownToClose: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Show alert for iOS
|
||||
Alert.alert(
|
||||
t("player.downloaded_file_title"),
|
||||
t("player.downloaded_file_message"),
|
||||
[
|
||||
{
|
||||
text: t("player.downloaded_file_yes"),
|
||||
onPress: () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
},
|
||||
isPreferred: true,
|
||||
},
|
||||
{
|
||||
text: t("player.downloaded_file_no"),
|
||||
onPress: () => {
|
||||
handleNormalPlayFlow();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t("player.downloaded_file_cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If not downloaded, proceed with normal flow
|
||||
handleNormalPlayFlow();
|
||||
}, [
|
||||
item,
|
||||
lightHapticFeedback,
|
||||
handleNormalPlayFlow,
|
||||
goToPlayer,
|
||||
t,
|
||||
showModal,
|
||||
hideModal,
|
||||
effectiveColors,
|
||||
]);
|
||||
|
||||
const derivedTargetWidth = useDerivedValue(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -64,6 +65,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
// Flatten all pages into a single array
|
||||
const allItems = data?.pages.flat() || [];
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||
return allItems.map((_, index) => index * itemWidth);
|
||||
}, [allItems, orientation]);
|
||||
|
||||
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
|
||||
if (disabled || !title) return null;
|
||||
|
||||
@@ -126,6 +132,8 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{allItems.map((item) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -67,6 +67,12 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
[api, user?.Id, collection?.Id],
|
||||
);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = 120; // w-28 (112px) + mr-2 (8px)
|
||||
// Generate offsets for a reasonable number of items
|
||||
return Array.from({ length: 50 }, (_, index) => index * itemWidth);
|
||||
}, []);
|
||||
|
||||
if (!collection) return null;
|
||||
|
||||
return (
|
||||
@@ -92,6 +98,8 @@ export const MediaListSection: React.FC<Props> = ({
|
||||
)}
|
||||
queryFn={fetchItems}
|
||||
queryKey={["media-list", collection.Id!]}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollRef.current?.scrollToIndex(index, 16);
|
||||
scrollRef.current?.scrollToIndex(index, -16);
|
||||
};
|
||||
|
||||
const seasonId = useMemo(() => {
|
||||
@@ -87,6 +87,11 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
}
|
||||
}, [episodes, item]);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = 184; // w-44 (176px) + mr-2 (8px)
|
||||
return episodes?.map((_, index) => index * itemWidth) || [];
|
||||
}, [episodes]);
|
||||
|
||||
return (
|
||||
<HorizontalScroll
|
||||
ref={scrollRef}
|
||||
@@ -109,6 +114,8 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
<ItemCardText item={_item} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
76
components/settings/ControlsSettings.tsx
Normal file
76
components/settings/ControlsSettings.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { Switch } from "react-native";
|
||||
import { ListItem } from "@/components/list/ListItem";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import DisabledSetting from "./DisabledSetting";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const ControlsSettings: React.FC<Props> = ({ ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
|
||||
const disabled = useMemo(
|
||||
() =>
|
||||
pluginSettings?.showVolumeSlider?.locked === true &&
|
||||
pluginSettings?.showBrightnessSlider?.locked === true &&
|
||||
pluginSettings?.showSeekButtons?.locked === true,
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
<DisabledSetting disabled={disabled} {...props}>
|
||||
<ListGroup title={t("home.settings.controls.controls_title")}>
|
||||
<ListItem
|
||||
title={t("home.settings.controls.show_volume_slider")}
|
||||
subtitle={t("home.settings.controls.show_volume_slider_description")}
|
||||
disabled={pluginSettings?.showVolumeSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showVolumeSlider}
|
||||
disabled={pluginSettings?.showVolumeSlider?.locked}
|
||||
onValueChange={(showVolumeSlider) =>
|
||||
updateSettings({ showVolumeSlider })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.controls.show_brightness_slider")}
|
||||
subtitle={t(
|
||||
"home.settings.controls.show_brightness_slider_description",
|
||||
)}
|
||||
disabled={pluginSettings?.showBrightnessSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showBrightnessSlider}
|
||||
disabled={pluginSettings?.showBrightnessSlider?.locked}
|
||||
onValueChange={(showBrightnessSlider) =>
|
||||
updateSettings({ showBrightnessSlider })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.controls.show_seek_buttons")}
|
||||
subtitle={t("home.settings.controls.show_seek_buttons_description")}
|
||||
disabled={pluginSettings?.showSeekButtons?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.showSeekButtons}
|
||||
disabled={pluginSettings?.showSeekButtons?.locked}
|
||||
onValueChange={(showSeekButtons) =>
|
||||
updateSettings({ showSeekButtons })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
};
|
||||
@@ -48,49 +48,57 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
}}
|
||||
pointerEvents={showControls ? "box-none" : "none"}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
transform: [{ rotate: "270deg" }],
|
||||
left: 0,
|
||||
bottom: 30,
|
||||
}}
|
||||
>
|
||||
<BrightnessSlider />
|
||||
</View>
|
||||
{settings?.showBrightnessSlider && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
transform: [{ rotate: "270deg" }],
|
||||
left: 0,
|
||||
bottom: 30,
|
||||
}}
|
||||
>
|
||||
<BrightnessSlider />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
{!Platform.isTV ? (
|
||||
settings?.showSeekButtons ? (
|
||||
<TouchableOpacity onPress={handleSkipBackward}>
|
||||
<View
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{settings?.rewindSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
style={{
|
||||
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{settings?.rewindSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View
|
||||
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<View style={Platform.isTV ? { flex: 1, alignItems: "center" } : {}}>
|
||||
<TouchableOpacity onPress={togglePlay}>
|
||||
@@ -106,47 +114,55 @@ export const CenterControls: FC<CenterControlsProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!Platform.isTV && (
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
style={{
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
<Text
|
||||
{!Platform.isTV ? (
|
||||
settings?.showSeekButtons ? (
|
||||
<TouchableOpacity onPress={handleSkipForward}>
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{settings?.forwardSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Ionicons
|
||||
name='refresh-outline'
|
||||
size={ICON_SIZES.CENTER}
|
||||
color='white'
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
{settings?.forwardSkipTime}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View
|
||||
style={{ width: ICON_SIZES.CENTER, height: ICON_SIZES.CENTER }}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
transform: [{ rotate: "270deg" }],
|
||||
bottom: 30,
|
||||
right: 0,
|
||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||
</View>
|
||||
{settings?.showVolumeSlider && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
alignItems: "center",
|
||||
transform: [{ rotate: "270deg" }],
|
||||
bottom: 30,
|
||||
right: 0,
|
||||
opacity: showAudioSlider || showControls ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<AudioSlider setVisibility={setShowAudioSlider} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -244,6 +244,22 @@ export class JellyseerrApi {
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async approveRequest(requestId: number): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.post<MediaRequest>(
|
||||
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async declineRequest(requestId: number): Promise<MediaRequest> {
|
||||
return this.axios
|
||||
?.post<MediaRequest>(
|
||||
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`,
|
||||
)
|
||||
.then(({ data }) => data);
|
||||
}
|
||||
|
||||
async requests(
|
||||
params = {
|
||||
filter: "all",
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
const { withAppDelegate, withXcodeProject } = require("expo/config-plugins");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
/** @param {import("expo/config-plugins").ExpoConfig} config */
|
||||
function withRNBackgroundDownloader(config) {
|
||||
/* 1️⃣ Add handleEventsForBackgroundURLSession to AppDelegate.swift */
|
||||
config = withAppDelegate(config, (mod) => {
|
||||
const tag = "handleEventsForBackgroundURLSession";
|
||||
if (!mod.modResults.contents.includes(tag)) {
|
||||
mod.modResults.contents = mod.modResults.contents.replace(
|
||||
/\}\s*$/, // insert before final }
|
||||
`
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
||||
}
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return mod;
|
||||
});
|
||||
|
||||
/* 2️⃣ Ensure bridging header exists & is attached to *every* app target */
|
||||
config = withXcodeProject(config, (mod) => {
|
||||
const project = mod.modResults;
|
||||
const projectName = config.name || "App";
|
||||
// Fix: Go up one more directory to get to ios/, not ios/ProjectName.xcodeproj/
|
||||
const iosDir = path.dirname(path.dirname(project.filepath));
|
||||
const headerRel = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||
const headerAbs = path.join(iosDir, headerRel);
|
||||
|
||||
// create / append import if missing
|
||||
let headerText = "";
|
||||
try {
|
||||
headerText = fs.readFileSync(headerAbs, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!headerText.includes("RNBackgroundDownloader.h")) {
|
||||
fs.mkdirSync(path.dirname(headerAbs), { recursive: true });
|
||||
fs.appendFileSync(headerAbs, '#import "RNBackgroundDownloader.h"\n');
|
||||
}
|
||||
|
||||
// Expo 53's xcode‑js doesn't expose pbxTargets().
|
||||
// Setting the property once at the project level is sufficient.
|
||||
["Debug", "Release"].forEach((cfg) => {
|
||||
// Use the detected projectName to set the bridging header path instead of a hardcoded value
|
||||
const bridgingHeaderPath = `${projectName}/${projectName}-Bridging-Header.h`;
|
||||
project.updateBuildProperty(
|
||||
"SWIFT_OBJC_BRIDGING_HEADER",
|
||||
bridgingHeaderPath,
|
||||
cfg,
|
||||
);
|
||||
});
|
||||
|
||||
return mod;
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = withRNBackgroundDownloader;
|
||||
@@ -113,6 +113,15 @@
|
||||
"right_side_volume": "Right Side Volume Control",
|
||||
"right_side_volume_description": "Swipe up/down on right side to adjust volume"
|
||||
},
|
||||
"controls": {
|
||||
"controls_title": "Controls",
|
||||
"show_volume_slider": "Show Volume Slider",
|
||||
"show_volume_slider_description": "Display volume slider on the right side of video controls",
|
||||
"show_brightness_slider": "Show Brightness Slider",
|
||||
"show_brightness_slider_description": "Display brightness slider on the left side of video controls",
|
||||
"show_seek_buttons": "Show Seek Buttons",
|
||||
"show_seek_buttons_description": "Display forward/rewind buttons next to the play button"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
"set_audio_track": "Set Audio Track From Previous Item",
|
||||
@@ -429,7 +438,12 @@
|
||||
"playback_state": "Playback State:",
|
||||
"index": "Index:",
|
||||
"continue_watching": "Continue Watching",
|
||||
"go_back": "Go Back"
|
||||
"go_back": "Go Back",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Cancel"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Next Up",
|
||||
@@ -514,6 +528,10 @@
|
||||
"number_episodes": "{{episode_number}} Episodes",
|
||||
"born": "Born",
|
||||
"appearances": "Appearances",
|
||||
"approve": "Approve",
|
||||
"decline": "Decline",
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"jellyseerr_test_failed": "Seerr test failed. Please try again.",
|
||||
@@ -521,7 +539,11 @@
|
||||
"issue_submitted": "Issue Submitted!",
|
||||
"requested_item": "Requested {{item}}!",
|
||||
"you_dont_have_permission_to_request": "You don't have permission to request!",
|
||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!"
|
||||
"something_went_wrong_requesting_media": "Something went wrong requesting media!",
|
||||
"request_approved": "Request Approved!",
|
||||
"request_declined": "Request Declined!",
|
||||
"failed_to_approve_request": "Failed to Approve Request",
|
||||
"failed_to_decline_request": "Failed to Decline Request"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
|
||||
@@ -180,6 +180,10 @@ export type Settings = {
|
||||
enableRightSideVolumeSwipe: boolean;
|
||||
usePopularPlugin: boolean;
|
||||
showLargeHomeCarousel: boolean;
|
||||
// Controls
|
||||
showVolumeSlider: boolean;
|
||||
showBrightnessSlider: boolean;
|
||||
showSeekButtons: boolean;
|
||||
};
|
||||
|
||||
export interface Lockable<T> {
|
||||
@@ -244,6 +248,10 @@ export const defaultValues: Settings = {
|
||||
enableRightSideVolumeSwipe: true,
|
||||
usePopularPlugin: true,
|
||||
showLargeHomeCarousel: false,
|
||||
// Controls
|
||||
showVolumeSlider: true,
|
||||
showBrightnessSlider: true,
|
||||
showSeekButtons: true,
|
||||
};
|
||||
|
||||
const loadSettings = (): Partial<Settings> => {
|
||||
|
||||
Reference in New Issue
Block a user