mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-14 21:36:20 +00:00
fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic
This commit is contained in:
223
components/AccountsSheet.tsx
Normal file
223
components/AccountsSheet.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
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";
|
||||
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 {
|
||||
deleteAccountCredential,
|
||||
type SavedServer,
|
||||
type SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface AccountsSheetProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
server: SavedServer | null;
|
||||
onAccountSelect: (account: SavedServerAccount) => void;
|
||||
onAddAccount: () => void;
|
||||
onAccountDeleted?: () => void;
|
||||
}
|
||||
|
||||
export const AccountsSheet: React.FC<AccountsSheetProps> = ({
|
||||
open,
|
||||
setOpen,
|
||||
server,
|
||||
onAccountSelect,
|
||||
onAddAccount,
|
||||
onAccountDeleted,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeleteAccount = async (account: SavedServerAccount) => {
|
||||
if (!server) return;
|
||||
|
||||
Alert.alert(
|
||||
t("server.remove_saved_login"),
|
||||
t("server.remove_account_description", { username: account.username }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.remove"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteAccountCredential(server.address, account.userId);
|
||||
onAccountDeleted?.();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const getSecurityIcon = (
|
||||
securityType: SavedServerAccount["securityType"],
|
||||
): keyof typeof Ionicons.glyphMap => {
|
||||
switch (securityType) {
|
||||
case "pin":
|
||||
return "keypad";
|
||||
case "password":
|
||||
return "lock-closed";
|
||||
default:
|
||||
return "key";
|
||||
}
|
||||
};
|
||||
|
||||
const renderRightActions = (account: SavedServerAccount) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteAccount(account)}
|
||||
className='bg-red-600 justify-center items-center px-5'
|
||||
>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
handleIndicatorStyle={{ backgroundColor: "white" }}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-4'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("server.select_account")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>
|
||||
{server.name || server.address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Account List */}
|
||||
<View className='bg-neutral-800 rounded-xl overflow-hidden mb-4'>
|
||||
{server.accounts.map((account, index) => (
|
||||
<Swipeable
|
||||
key={account.userId}
|
||||
renderRightActions={() => renderRightActions(account)}
|
||||
overshootRight={false}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
onAccountSelect(account);
|
||||
}}
|
||||
className={`flex-row items-center p-4 bg-neutral-800 ${
|
||||
index < server.accounts.length - 1
|
||||
? "border-b border-neutral-700"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||
<Ionicons name='person' size={20} color='white' />
|
||||
</View>
|
||||
|
||||
{/* Account Info */}
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-100 font-medium'>
|
||||
{account.username}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{account.securityType === "none"
|
||||
? t("save_account.no_protection")
|
||||
: account.securityType === "pin"
|
||||
? t("save_account.pin_code")
|
||||
: t("save_account.password")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Security Icon */}
|
||||
<Ionicons
|
||||
name={getSecurityIcon(account.securityType)}
|
||||
size={18}
|
||||
color={Colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Swipeable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Hint */}
|
||||
<Text className='text-xs text-neutral-500 mb-4 ml-1'>
|
||||
{t("server.swipe_to_remove")}
|
||||
</Text>
|
||||
|
||||
{/* Add Account Button */}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setOpen(false);
|
||||
onAddAccount();
|
||||
}}
|
||||
color='purple'
|
||||
>
|
||||
<View className='flex-row items-center justify-center'>
|
||||
<Ionicons name='add' size={20} color='white' />
|
||||
<Text className='text-white font-semibold ml-2'>
|
||||
{t("server.add_account")}
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const AddToFavorites: FC<Props> = ({ item, ...props }) => {
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isFavorite ? "heart" : "heart-outline"}
|
||||
color={isFavorite ? "purple" : "white"}
|
||||
onPress={toggleFavorite}
|
||||
/>
|
||||
</View>
|
||||
|
||||
43
components/AddToWatchlist.tsx
Normal file
43
components/AddToWatchlist.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import {
|
||||
WatchlistSheet,
|
||||
type WatchlistSheetRef,
|
||||
} from "@/components/watchlists/WatchlistSheet";
|
||||
import {
|
||||
useItemInWatchlists,
|
||||
useStreamystatsEnabled,
|
||||
} from "@/hooks/useWatchlists";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const AddToWatchlist: FC<Props> = ({ item, ...props }) => {
|
||||
const streamystatsEnabled = useStreamystatsEnabled();
|
||||
const sheetRef = useRef<WatchlistSheetRef>(null);
|
||||
|
||||
const { data: watchlistsContainingItem } = useItemInWatchlists(item.Id);
|
||||
const isInAnyWatchlist = (watchlistsContainingItem?.length ?? 0) > 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
sheetRef.current?.open(item);
|
||||
}, [item]);
|
||||
|
||||
// Don't render if Streamystats is not enabled
|
||||
if (!streamystatsEnabled) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
size='large'
|
||||
icon={isInAnyWatchlist ? "list" : "list-outline"}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
<WatchlistSheet ref={sheetRef} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -13,6 +14,30 @@ export const Badge: React.FC<Props> = ({
|
||||
variant = "purple",
|
||||
...props
|
||||
}) => {
|
||||
const content = (
|
||||
<View style={styles.content}>
|
||||
{iconLeft && <View style={styles.iconLeft}>{iconLeft}</View>}
|
||||
<Text
|
||||
className={`
|
||||
text-xs
|
||||
${variant === "purple" && "text-white"}
|
||||
`}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View {...props} style={[styles.container, props.style]}>
|
||||
<GlassEffectView style={{ borderRadius: 100 }}>
|
||||
{content}
|
||||
</GlassEffectView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
@@ -34,3 +59,23 @@ export const Badge: React.FC<Props> = ({
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
alignSelf: "flex-start",
|
||||
flexShrink: 1,
|
||||
flexGrow: 0,
|
||||
},
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 50,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
iconLeft: {
|
||||
marginRight: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import GoogleCast, {
|
||||
CastButton,
|
||||
CastContext,
|
||||
@@ -44,7 +45,7 @@ export function Chromecast({
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
className='mr-4'
|
||||
onPress={() => {
|
||||
if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
|
||||
@@ -54,7 +55,7 @@ export function Chromecast({
|
||||
>
|
||||
<AndroidCastButton />
|
||||
<Feather name='cast' size={22} color={"white"} />
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||
@@ -109,7 +111,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
pathname: "/series/[id]",
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
id: firstItem.SeriesId!,
|
||||
seasonIndex: firstItem.ParentIndexNumber?.toString(),
|
||||
offline: "true",
|
||||
},
|
||||
} as Href),
|
||||
);
|
||||
@@ -209,6 +213,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||
deviceId: api.deviceInfo.id,
|
||||
audioMode: settings?.audioTranscodeMode,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -236,11 +241,23 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Get the audio/subtitle indices that were used for this download
|
||||
const downloadAudioIndex =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!).audioIndex
|
||||
: selectedOptions?.audioIndex;
|
||||
const downloadSubtitleIndex =
|
||||
itemsNotDownloaded.length > 1
|
||||
? getDefaultPlaySettings(item, settings!).subtitleIndex
|
||||
: selectedOptions?.subtitleIndex;
|
||||
|
||||
await startBackgroundDownload(
|
||||
url,
|
||||
item,
|
||||
mediaSource,
|
||||
selectedOptions?.bitrate || defaultBitrate,
|
||||
downloadAudioIndex,
|
||||
downloadSubtitleIndex,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Example Usage of Global Modal
|
||||
*
|
||||
* This file demonstrates how to use the global modal system from anywhere in your app.
|
||||
* You can delete this file after understanding how it works.
|
||||
*/
|
||||
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
* Example 1: Simple Content Modal
|
||||
*/
|
||||
export const SimpleModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>Simple Modal</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This is a simple modal with just some text content.
|
||||
</Text>
|
||||
<Text className='text-neutral-400'>
|
||||
Swipe down or tap outside to close.
|
||||
</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-purple-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Open Simple Modal</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 2: Modal with Custom Snap Points
|
||||
*/
|
||||
export const CustomSnapPointsExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(
|
||||
<View className='p-6' style={{ minHeight: 400 }}>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Custom Snap Points
|
||||
</Text>
|
||||
<Text className='text-white mb-4'>
|
||||
This modal has custom snap points (25%, 50%, 90%).
|
||||
</Text>
|
||||
<View className='bg-neutral-800 p-4 rounded-lg'>
|
||||
<Text className='text-white'>
|
||||
Try dragging the modal to different heights!
|
||||
</Text>
|
||||
</View>
|
||||
</View>,
|
||||
{
|
||||
snapPoints: ["25%", "50%", "90%"],
|
||||
enableDynamicSizing: false,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-blue-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Custom Snap Points</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 3: Complex Component in Modal
|
||||
*/
|
||||
const SettingsModalContent = () => {
|
||||
const { hideModal } = useGlobalModal();
|
||||
|
||||
const settings = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Notifications",
|
||||
icon: "notifications-outline" as const,
|
||||
enabled: true,
|
||||
},
|
||||
{ id: 2, title: "Dark Mode", icon: "moon-outline" as const, enabled: true },
|
||||
{
|
||||
id: 3,
|
||||
title: "Auto-play",
|
||||
icon: "play-outline" as const,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View className='p-6'>
|
||||
<Text className='text-2xl font-bold mb-6 text-white'>Settings</Text>
|
||||
|
||||
{settings.map((setting, index) => (
|
||||
<View
|
||||
key={setting.id}
|
||||
className={`flex-row items-center justify-between py-4 ${
|
||||
index !== settings.length - 1 ? "border-b border-neutral-700" : ""
|
||||
}`}
|
||||
>
|
||||
<View className='flex-row items-center gap-3'>
|
||||
<Ionicons name={setting.icon} size={24} color='white' />
|
||||
<Text className='text-white text-lg'>{setting.title}</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-12 h-7 rounded-full ${
|
||||
setting.enabled ? "bg-purple-600" : "bg-neutral-600"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform ${
|
||||
setting.enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={hideModal}
|
||||
className='bg-purple-600 px-4 py-3 rounded-lg mt-6'
|
||||
>
|
||||
<Text className='text-white font-semibold text-center'>Close</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComplexModalExample = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
showModal(<SettingsModalContent />);
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleOpenModal}
|
||||
className='bg-green-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Complex Component</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example 4: Modal Triggered from Function (e.g., API response)
|
||||
*/
|
||||
export const useShowSuccessModal = () => {
|
||||
const { showModal } = useGlobalModal();
|
||||
|
||||
return (message: string) => {
|
||||
showModal(
|
||||
<View className='p-6 items-center'>
|
||||
<View className='bg-green-500 rounded-full p-4 mb-4'>
|
||||
<Ionicons name='checkmark' size={48} color='white' />
|
||||
</View>
|
||||
<Text className='text-2xl font-bold mb-2 text-white'>Success!</Text>
|
||||
<Text className='text-white text-center'>{message}</Text>
|
||||
</View>,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Demo Component
|
||||
*/
|
||||
export const GlobalModalDemo = () => {
|
||||
const showSuccess = useShowSuccessModal();
|
||||
|
||||
return (
|
||||
<View className='p-6 gap-4'>
|
||||
<Text className='text-2xl font-bold mb-4 text-white'>
|
||||
Global Modal Examples
|
||||
</Text>
|
||||
|
||||
<SimpleModalExample />
|
||||
<CustomSnapPointsExample />
|
||||
<ComplexModalExample />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => showSuccess("Operation completed successfully!")}
|
||||
className='bg-orange-600 px-4 py-2 rounded-lg'
|
||||
>
|
||||
<Text className='text-white font-semibold'>Show Success Modal</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
// GenreTags.tsx
|
||||
import type React from "react";
|
||||
import {
|
||||
Platform,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface TagProps {
|
||||
@@ -20,6 +23,23 @@ export const Tag: React.FC<
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
} & ViewProps
|
||||
> = ({ text, textClass, textStyle, ...props }) => {
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<View>
|
||||
<GlassEffectView style={styles.glass}>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
>
|
||||
<Text>{text}</Text>
|
||||
</View>
|
||||
</GlassEffectView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='bg-neutral-800 rounded-full px-2 py-1' {...props}>
|
||||
<Text className={textClass} style={textStyle}>
|
||||
@@ -29,6 +49,16 @@ export const Tag: React.FC<
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
borderRadius: 50,
|
||||
},
|
||||
glass: {
|
||||
borderRadius: 50,
|
||||
},
|
||||
});
|
||||
|
||||
export const Tags: React.FC<
|
||||
TagProps & { tagProps?: ViewProps } & ViewProps
|
||||
> = ({ tags, textClass = "text-xs", tagProps, ...props }) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
|
||||
/**
|
||||
@@ -16,7 +16,13 @@ import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
* after BottomSheetModalProvider.
|
||||
*/
|
||||
export const GlobalModal = () => {
|
||||
const { hideModal, modalState, modalRef } = useGlobalModal();
|
||||
const { hideModal, modalState, modalRef, isVisible } = useGlobalModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && modalState.content) {
|
||||
modalRef.current?.present();
|
||||
}
|
||||
}, [isVisible, modalState.content, modalRef]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
|
||||
203
components/IntroSheet.tsx
Normal file
203
components/IntroSheet.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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";
|
||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||
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 { storage } from "@/utils/mmkv";
|
||||
|
||||
export interface IntroSheetRef {
|
||||
present: () => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
present: () => {
|
||||
storage.set("hasShownIntro", true);
|
||||
bottomSheetRef.current?.present();
|
||||
},
|
||||
dismiss: () => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
},
|
||||
}));
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}, []);
|
||||
|
||||
const handleGoToSettings = useCallback(() => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
router.push("/settings");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
enableDynamicSizing
|
||||
backdropComponent={renderBackdrop}
|
||||
backgroundStyle={{ backgroundColor: "#171717" }}
|
||||
handleIndicatorStyle={{ backgroundColor: "#737373" }}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
}}
|
||||
>
|
||||
<View className={Platform.isTV ? "py-5 space-y-4" : "py-4 space-y-6"}>
|
||||
<View>
|
||||
<Text className='text-3xl font-bold text-center mb-2'>
|
||||
{t("home.intro.welcome_to_streamyfin")}
|
||||
</Text>
|
||||
<Text className='text-center'>
|
||||
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className='text-lg font-bold'>
|
||||
{t("home.intro.features_title")}
|
||||
</Text>
|
||||
<Text className='text-xs'>
|
||||
{t("home.intro.features_description")}
|
||||
</Text>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<Image
|
||||
source={require("@/assets/icons/seerr-logo.svg")}
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
/>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Seerr</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.seerr_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Ionicons
|
||||
name='cloud-download-outline'
|
||||
size={32}
|
||||
color='white'
|
||||
/>
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.downloads_feature_title")}
|
||||
</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.downloads_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Feather name='cast' size={28} color={"white"} />
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||
<Text className='shrink text-xs'>
|
||||
{t("home.intro.chromecast_feature_description")}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View className='flex flex-row items-center mt-4'>
|
||||
<View
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
className='flex items-center justify-center'
|
||||
>
|
||||
<Feather name='settings' size={28} color={"white"} />
|
||||
</View>
|
||||
<View className='shrink ml-2'>
|
||||
<Text className='font-bold mb-1'>
|
||||
{t("home.intro.centralized_settings_plugin_title")}
|
||||
</Text>
|
||||
<View className='flex-row flex-wrap items-baseline'>
|
||||
<Text className='shrink text-xs'>
|
||||
{t(
|
||||
"home.intro.centralized_settings_plugin_description",
|
||||
)}{" "}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL(
|
||||
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text className='text-xs text-purple-600 underline'>
|
||||
{t("home.intro.read_more")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Button onPress={handleDismiss} className='mt-4'>
|
||||
{t("home.intro.done_button")}
|
||||
</Button>
|
||||
<TouchableOpacity onPress={handleGoToSettings} className='mt-4'>
|
||||
<Text className='text-purple-600 text-center'>
|
||||
{t("home.intro.go_to_settings_button")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={{ height: insets.bottom }} />
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
});
|
||||
|
||||
IntroSheet.displayName = "IntroSheet";
|
||||
@@ -11,6 +11,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { ItemPeopleSections } from "@/components/item/ItemPeopleSections";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -18,7 +19,6 @@ import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { PlayedStatus } from "@/components/PlayedStatus";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
@@ -26,12 +26,13 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { AddToWatchlist } from "./AddToWatchlist";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
@@ -45,13 +46,13 @@ export type SelectedOptions = {
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline, itemWithSources }) => {
|
||||
({ item, itemWithSources }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
@@ -67,18 +68,23 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// Use itemWithSources for play settings since it has MediaSources data
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
defaultMediaSource,
|
||||
defaultSubtitleIndex,
|
||||
} = useDefaultPlaySettings(item!, settings);
|
||||
} = useDefaultPlaySettings(itemWithSources ?? item, settings);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
() => (item ? getLogoImageUrlById({ api, item }) : null),
|
||||
[api, item],
|
||||
);
|
||||
|
||||
const onLogoLoad = React.useCallback(() => {
|
||||
setLoadingLogo(false);
|
||||
}, []);
|
||||
|
||||
const loading = useMemo(() => {
|
||||
return Boolean(logoUrl && loadingLogo);
|
||||
}, [loadingLogo, logoUrl]);
|
||||
@@ -87,7 +93,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
useEffect(() => {
|
||||
setSelectedOptions(() => ({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
}));
|
||||
@@ -111,12 +117,17 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator &&
|
||||
!settings.hideRemoteSessionButton && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -128,19 +139,32 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{!Platform.isTV && (
|
||||
<DownloadSingleItem item={itemWithSources} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
{user?.Policy?.IsAdministrator &&
|
||||
!settings.hideRemoteSessionButton && (
|
||||
<PlayInRemoteSessionButton item={item} size='large' />
|
||||
)}
|
||||
|
||||
<PlayedStatus items={[item]} size='large' />
|
||||
<AddToFavorites item={item} />
|
||||
{settings.streamyStatsServerUrl &&
|
||||
!settings.hideWatchlistsTab && (
|
||||
<AddToWatchlist item={item} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)),
|
||||
});
|
||||
}
|
||||
}, [item, navigation, user, itemWithSources]);
|
||||
}, [
|
||||
item,
|
||||
navigation,
|
||||
user,
|
||||
itemWithSources,
|
||||
settings.hideRemoteSessionButton,
|
||||
settings.streamyStatsServerUrl,
|
||||
settings.hideWatchlistsTab,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
@@ -162,7 +186,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}}
|
||||
>
|
||||
<ParallaxScrollView
|
||||
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
|
||||
className='flex-1'
|
||||
headerHeight={headerHeight}
|
||||
headerImage={
|
||||
<View style={[{ flex: 1 }]}>
|
||||
@@ -189,8 +213,8 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
width: "100%",
|
||||
}}
|
||||
contentFit='contain'
|
||||
onLoad={() => setLoadingLogo(false)}
|
||||
onError={() => setLoadingLogo(false)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoLoad}
|
||||
/>
|
||||
) : (
|
||||
<View />
|
||||
@@ -205,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
@@ -220,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
</View>
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
@@ -238,25 +257,10 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
{item.Type !== "Program" && (
|
||||
<>
|
||||
{item.Type === "Episode" && !isOffline && (
|
||||
<CurrentSeries item={item} className='mb-4' />
|
||||
<CurrentSeries item={item} className='mb-2' />
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<CastAndCrew item={item} className='mb-4' loading={loading} />
|
||||
)}
|
||||
|
||||
{item.People && item.People.length > 0 && !isOffline && (
|
||||
<View className='mb-4'>
|
||||
{item.People.slice(0, 3).map((person, idx) => (
|
||||
<MoreMoviesWithActor
|
||||
currentItem={item}
|
||||
key={idx}
|
||||
actorId={person.Id!}
|
||||
className='mb-4'
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<ItemPeopleSections item={item} />
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
|
||||
@@ -183,6 +183,12 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
|
||||
if (!source || !videoStream) return null;
|
||||
|
||||
// Dolby Vision video check
|
||||
const isDolbyVision =
|
||||
videoStream.VideoRangeType === "DOVI" ||
|
||||
videoStream.DvVersionMajor != null ||
|
||||
videoStream.DvVersionMinor != null;
|
||||
|
||||
return (
|
||||
<View className='flex-row flex-wrap gap-2'>
|
||||
<Badge
|
||||
@@ -195,6 +201,15 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
|
||||
iconLeft={<Ionicons name='film-outline' size={16} color='white' />}
|
||||
text={`${videoStream.Width}x${videoStream.Height}`}
|
||||
/>
|
||||
{isDolbyVision && (
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
<Ionicons name='sparkles-outline' size={16} color='white' />
|
||||
}
|
||||
text={"DV"}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant='gray'
|
||||
iconLeft={
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
@@ -11,15 +12,19 @@ import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||
import { ItemCardText } from "@/components/ItemCardText";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
interface Props extends ViewProps {
|
||||
actorId: string;
|
||||
actorName?: string | null;
|
||||
currentItem: BaseItemDto;
|
||||
}
|
||||
|
||||
export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
actorId,
|
||||
actorName,
|
||||
currentItem,
|
||||
...props
|
||||
}) => {
|
||||
@@ -27,19 +32,6 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: actor } = useQuery({
|
||||
queryKey: ["actor", actorId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return null;
|
||||
return await getUserItemData({
|
||||
api,
|
||||
userId: user.Id,
|
||||
itemId: actorId,
|
||||
});
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!actorId,
|
||||
});
|
||||
|
||||
const { data: items, isLoading } = useQuery({
|
||||
queryKey: ["actor", "movies", actorId, currentItem.Id],
|
||||
queryFn: async () => {
|
||||
@@ -72,29 +64,34 @@ export const MoreMoviesWithActor: React.FC<Props> = ({
|
||||
enabled: !!api && !!user?.Id && !!actorId,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
key={item.Id ?? idx}
|
||||
item={item}
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (items?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>
|
||||
{t("item_card.more_with", { name: actor?.Name })}
|
||||
{t("item_card.more_with", { name: actorName ?? "" })}
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
key={idx}
|
||||
item={item}
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<View>
|
||||
<MoviePoster item={item} />
|
||||
<ItemCardText item={item} />
|
||||
</View>
|
||||
</TouchableItemRouter>
|
||||
)}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
231
components/PINEntryModal.tsx
Normal file
231
components/PINEntryModal.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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 {
|
||||
Alert,
|
||||
Animated,
|
||||
Keyboard,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { verifyAccountPIN } from "@/utils/secureCredentials";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { PinInput } from "./inputs/PinInput";
|
||||
|
||||
interface PINEntryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
onForgotPIN?: () => void;
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const PINEntryModal: React.FC<PINEntryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onForgotPIN,
|
||||
serverUrl,
|
||||
userId,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const shakeAnimation = useRef(new Animated.Value(0)).current;
|
||||
const errorHaptic = useHaptic("error");
|
||||
const successHaptic = useHaptic("success");
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
setPinCode("");
|
||||
setError(null);
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setPinCode("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const shake = () => {
|
||||
Animated.sequence([
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 10,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: -10,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 10,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(shakeAnimation, {
|
||||
toValue: 0,
|
||||
duration: 50,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
|
||||
const handlePinChange = async (value: string) => {
|
||||
setPinCode(value);
|
||||
setError(null);
|
||||
|
||||
// Auto-verify when 4 digits entered
|
||||
if (value.length === 4) {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const isValid = await verifyAccountPIN(serverUrl, userId, value);
|
||||
if (isValid) {
|
||||
Keyboard.dismiss();
|
||||
successHaptic();
|
||||
onSuccess();
|
||||
setPinCode("");
|
||||
} else {
|
||||
errorHaptic();
|
||||
setError(t("pin.invalid_pin"));
|
||||
shake();
|
||||
setPinCode("");
|
||||
}
|
||||
} catch {
|
||||
errorHaptic();
|
||||
setError(t("pin.invalid_pin"));
|
||||
shake();
|
||||
setPinCode("");
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPIN = () => {
|
||||
Alert.alert(t("pin.forgot_pin"), t("pin.forgot_pin_desc"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.continue"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
onClose();
|
||||
onForgotPIN?.();
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
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={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-6'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("pin.enter_pin")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>
|
||||
{t("pin.enter_pin_for", { username })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* PIN Input */}
|
||||
<Animated.View
|
||||
style={{ transform: [{ translateX: shakeAnimation }] }}
|
||||
className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'
|
||||
>
|
||||
<PinInput
|
||||
value={pinCode}
|
||||
onChangeText={handlePinChange}
|
||||
length={4}
|
||||
style={{ paddingHorizontal: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<Text className='text-red-500 text-center mt-3'>{error}</Text>
|
||||
)}
|
||||
{isVerifying && (
|
||||
<Text className='text-neutral-400 text-center mt-3'>
|
||||
{t("common.verifying") || "Verifying..."}
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Forgot PIN */}
|
||||
<TouchableOpacity onPress={handleForgotPIN} className='mb-4'>
|
||||
<Text className='text-purple-400 text-center'>
|
||||
{t("pin.forgot_pin")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<Button onPress={onClose} color='black'>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
185
components/PasswordEntryModal.tsx
Normal file
185
components/PasswordEntryModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
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 { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
|
||||
interface PasswordEntryModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (password: string) => Promise<void>;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const PasswordEntryModal: React.FC<PasswordEntryModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSubmit,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const errorHaptic = useHaptic("error");
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["50%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
setPassword("");
|
||||
setError(null);
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setPassword("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password) {
|
||||
setError(t("password.enter_password"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onSubmit(password);
|
||||
setPassword("");
|
||||
} catch {
|
||||
errorHaptic();
|
||||
setError(t("password.invalid_password"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
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={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-6'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("password.enter_password")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>
|
||||
{t("password.enter_password_for", { username })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Password Input */}
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||
<Text className='text-neutral-400 text-sm mb-2'>
|
||||
{t("login.password")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
value={password}
|
||||
onChangeText={(text) => {
|
||||
setPassword(text);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("login.password")}
|
||||
placeholderTextColor='#6B7280'
|
||||
secureTextEntry
|
||||
autoFocus
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
style={{
|
||||
backgroundColor: "#1F2937",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
}}
|
||||
onSubmitEditing={handleSubmit}
|
||||
returnKeyType='done'
|
||||
/>
|
||||
{error && <Text className='text-red-500 mt-2'>{error}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className='flex-row gap-3'>
|
||||
<Button
|
||||
onPress={onClose}
|
||||
color='black'
|
||||
className='flex-1'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleSubmit}
|
||||
color='purple'
|
||||
className='flex-1'
|
||||
disabled={isLoading || !password}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
) : (
|
||||
t("login.login")
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -54,9 +54,7 @@ const ToggleSwitch: React.FC<{ value: boolean }> = ({ value }) => (
|
||||
className={`w-12 h-7 rounded-full ${value ? "bg-purple-600" : "bg-neutral-600"} flex-row items-center`}
|
||||
>
|
||||
<View
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
className={`w-5 h-5 rounded-full bg-white shadow-md transform transition-transform ${value ? "translate-x-6" : "translate-x-1"}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -73,9 +71,7 @@ const OptionItem: React.FC<{ option: Option; isLast?: boolean }> = ({
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
disabled={option.disabled}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${
|
||||
option.disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`px-4 py-3 flex flex-row items-center justify-between ${option.disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Text className='flex-1 text-white'>{option.label}</Text>
|
||||
{isToggle ? (
|
||||
@@ -219,11 +215,7 @@ const PlatformDropdownComponent = ({
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger>
|
||||
<View className=''>
|
||||
{trigger || <Button variant='bordered'>Show Menu</Button>}
|
||||
</View>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
|
||||
<ContextMenu.Items>
|
||||
{groups.flatMap((group, groupIndex) => {
|
||||
// Check if this group has radio options
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Feather, Ionicons } 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, Platform, TouchableOpacity, View } from "react-native";
|
||||
import CastContext, {
|
||||
CastButton,
|
||||
MediaStreamType,
|
||||
PlayServicesState,
|
||||
useMediaStatus,
|
||||
useRemoteMediaClient,
|
||||
@@ -23,11 +23,13 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
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 { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
@@ -43,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -53,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
}: Props) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
@@ -185,11 +186,23 @@ export const PlayButton: React.FC<Props> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate start time in seconds from playback position
|
||||
const startTimeSeconds =
|
||||
(item?.UserData?.PlaybackPositionTicks ?? 0) / 10000000;
|
||||
|
||||
// Calculate stream duration in seconds from runtime
|
||||
const streamDurationSeconds = item.RunTimeTicks
|
||||
? item.RunTimeTicks / 10000000
|
||||
: undefined;
|
||||
|
||||
client
|
||||
.loadMedia({
|
||||
mediaInfo: {
|
||||
contentId: item.Id,
|
||||
contentUrl: data?.url,
|
||||
contentType: "video/mp4",
|
||||
streamType: MediaStreamType.BUFFERED,
|
||||
streamDuration: streamDurationSeconds,
|
||||
metadata:
|
||||
item.Type === "Episode"
|
||||
? {
|
||||
@@ -241,7 +254,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
],
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
startTime: startTimeSeconds,
|
||||
})
|
||||
.then(() => {
|
||||
// state is already set when reopening current media, so skip it here.
|
||||
@@ -280,7 +293,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
]);
|
||||
|
||||
const onPress = useCallback(async () => {
|
||||
console.log("onPress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -288,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// If online but file is downloaded, ask user which version to play
|
||||
if (downloadedItem) {
|
||||
if (Platform.OS === "android") {
|
||||
// Show bottom sheet for Android
|
||||
@@ -474,52 +499,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
),
|
||||
}));
|
||||
|
||||
// if (Platform.OS === "ios")
|
||||
// return (
|
||||
// <Host
|
||||
// style={{
|
||||
// height: 50,
|
||||
// flex: 1,
|
||||
// flexShrink: 0,
|
||||
// }}
|
||||
// >
|
||||
// <Button
|
||||
// variant='glassProminent'
|
||||
// onPress={onPress}
|
||||
// color={effectiveColors.primary}
|
||||
// modifiers={[fixedSize()]}
|
||||
// >
|
||||
// <View className='flex flex-row items-center space-x-2 h-full w-full justify-center -mb-3.5 '>
|
||||
// <Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
|
||||
// {runtimeTicksToMinutes(
|
||||
// (item?.RunTimeTicks || 0) -
|
||||
// (item?.UserData?.PlaybackPositionTicks || 0),
|
||||
// )}
|
||||
// {(item?.UserData?.PlaybackPositionTicks || 0) > 0 && " left"}
|
||||
// </Animated.Text>
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Ionicons name='play-circle' size={24} />
|
||||
// </Animated.Text>
|
||||
// {client && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <Feather name='cast' size={22} />
|
||||
// <CastButton tintColor='transparent' />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// {!client && settings?.openInVLC && (
|
||||
// <Animated.Text style={animatedTextStyle}>
|
||||
// <MaterialCommunityIcons
|
||||
// name='vlc'
|
||||
// size={18}
|
||||
// color={animatedTextStyle.color}
|
||||
// />
|
||||
// </Animated.Text>
|
||||
// )}
|
||||
// </View>
|
||||
// </Button>
|
||||
// </Host>
|
||||
// );
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={!item}
|
||||
@@ -569,15 +548,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<CastButton tintColor='transparent' />
|
||||
</Animated.Text>
|
||||
)}
|
||||
{!client && settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
@@ -14,10 +13,10 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import type { Button } from "./Button";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
@@ -50,7 +49,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
const startColor = useSharedValue(effectiveColors);
|
||||
const widthProgress = useSharedValue(0);
|
||||
const colorChangeProgress = useSharedValue(0);
|
||||
const { settings } = useSettings();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
|
||||
const goToPlayer = useCallback(
|
||||
@@ -61,7 +59,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const onPress = () => {
|
||||
console.log("onpress");
|
||||
if (!item) return;
|
||||
|
||||
lightHapticFeedback();
|
||||
@@ -207,15 +204,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<Ionicons name='play-circle' size={24} />
|
||||
</Animated.Text>
|
||||
{settings?.openInVLC && (
|
||||
<Animated.Text style={animatedTextStyle}>
|
||||
<MaterialCommunityIcons
|
||||
name='vlc'
|
||||
size={18}
|
||||
color={animatedTextStyle.color}
|
||||
/>
|
||||
</Animated.Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
180
components/PlaybackSpeedSelector.tsx
Normal file
180
components/PlaybackSpeedSelector.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
import { PlaybackSpeedScope } from "./video-player/controls/utils/playback-speed-settings";
|
||||
|
||||
export const PLAYBACK_SPEEDS = [
|
||||
{ label: "0.25x", value: 0.25 },
|
||||
{ label: "0.5x", value: 0.5 },
|
||||
{ label: "0.75x", value: 0.75 },
|
||||
{ label: "1x", value: 1.0 },
|
||||
{ label: "1.25x", value: 1.25 },
|
||||
{ label: "1.5x", value: 1.5 },
|
||||
{ label: "1.75x", value: 1.75 },
|
||||
{ label: "2x", value: 2.0 },
|
||||
{ label: "2.25x", value: 2.25 },
|
||||
{ label: "2.5x", value: 2.5 },
|
||||
{ label: "2.75x", value: 2.75 },
|
||||
{ label: "3x", value: 3.0 },
|
||||
];
|
||||
|
||||
interface Props extends React.ComponentProps<typeof View> {
|
||||
onChange: (value: number, scope: PlaybackSpeedScope) => void;
|
||||
selected: number;
|
||||
item?: BaseItemDto;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PlaybackSpeedSelector: React.FC<Props> = ({
|
||||
onChange,
|
||||
selected,
|
||||
item,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const isTv = Platform.isTV;
|
||||
const { t } = useTranslation();
|
||||
const { settings } = useSettings();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
||||
// Determine initial scope based on existing settings
|
||||
const initialScope = useMemo<PlaybackSpeedScope>(() => {
|
||||
if (!item || !settings) return PlaybackSpeedScope.All;
|
||||
|
||||
const itemId = item?.Id;
|
||||
if (!itemId) return PlaybackSpeedScope.All;
|
||||
|
||||
// Check for media-specific speed preference
|
||||
if (settings?.playbackSpeedPerMedia?.[itemId] !== undefined) {
|
||||
return PlaybackSpeedScope.Media;
|
||||
}
|
||||
|
||||
// Check for show-specific speed preference (only for episodes)
|
||||
const seriesId = item?.SeriesId;
|
||||
const perShowSettings = settings?.playbackSpeedPerShow;
|
||||
if (
|
||||
seriesId &&
|
||||
perShowSettings &&
|
||||
perShowSettings[seriesId] !== undefined
|
||||
) {
|
||||
return PlaybackSpeedScope.Show;
|
||||
}
|
||||
|
||||
// If no custom setting exists, check default playback speed
|
||||
// Show "All" if speed is not 1x, otherwise show "Media"
|
||||
return (settings?.defaultPlaybackSpeed ?? 1.0) !== 1.0
|
||||
? PlaybackSpeedScope.All
|
||||
: PlaybackSpeedScope.Media;
|
||||
}, [item?.Id, item?.SeriesId, settings]);
|
||||
|
||||
const [selectedScope, setSelectedScope] =
|
||||
useState<PlaybackSpeedScope>(initialScope);
|
||||
|
||||
// Update selectedScope when initialScope changes
|
||||
useEffect(() => {
|
||||
setSelectedScope(initialScope);
|
||||
}, [initialScope]);
|
||||
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const setOpen = onOpenChange || setInternalOpen;
|
||||
|
||||
const scopeLabels = useMemo<Record<PlaybackSpeedScope, string>>(() => {
|
||||
const labels: Record<string, string> = {
|
||||
[PlaybackSpeedScope.Media]: t("playback_speed.scope.media"),
|
||||
};
|
||||
|
||||
if (item?.SeriesId) {
|
||||
labels[PlaybackSpeedScope.Show] = t("playback_speed.scope.show");
|
||||
}
|
||||
|
||||
labels[PlaybackSpeedScope.All] = t("playback_speed.scope.all");
|
||||
|
||||
return labels as Record<PlaybackSpeedScope, string>;
|
||||
}, [item?.SeriesId, t]);
|
||||
|
||||
const availableScopes = useMemo<PlaybackSpeedScope[]>(() => {
|
||||
const scopes = [PlaybackSpeedScope.Media];
|
||||
if (item?.SeriesId) {
|
||||
scopes.push(PlaybackSpeedScope.Show);
|
||||
}
|
||||
scopes.push(PlaybackSpeedScope.All);
|
||||
return scopes;
|
||||
}, [item?.SeriesId]);
|
||||
|
||||
const handleSpeedSelect = useCallback(
|
||||
(speed: number) => {
|
||||
onChange(speed, selectedScope);
|
||||
setOpen(false);
|
||||
},
|
||||
[onChange, selectedScope, setOpen],
|
||||
);
|
||||
|
||||
const optionGroups = useMemo<OptionGroup[]>(() => {
|
||||
const groups: OptionGroup[] = [];
|
||||
|
||||
// Scope selection group
|
||||
groups.push({
|
||||
title: t("playback_speed.apply_to"),
|
||||
options: availableScopes.map((scope) => ({
|
||||
type: "radio" as const,
|
||||
label: scopeLabels[scope],
|
||||
value: scope,
|
||||
selected: selectedScope === scope,
|
||||
onPress: () => setSelectedScope(scope),
|
||||
})),
|
||||
});
|
||||
|
||||
// Speed selection group
|
||||
groups.push({
|
||||
title: t("playback_speed.speed"),
|
||||
options: PLAYBACK_SPEEDS.map((speed) => ({
|
||||
type: "radio" as const,
|
||||
label: speed.label,
|
||||
value: speed.value,
|
||||
selected: selected === speed.value,
|
||||
onPress: () => handleSpeedSelect(speed.value),
|
||||
})),
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [
|
||||
t,
|
||||
availableScopes,
|
||||
scopeLabels,
|
||||
selectedScope,
|
||||
selected,
|
||||
handleSpeedSelect,
|
||||
]);
|
||||
|
||||
const trigger = useMemo(
|
||||
() => (
|
||||
<View className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'>
|
||||
<Ionicons name='speedometer' size={24} color='white' />
|
||||
</View>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (isTv) return null;
|
||||
|
||||
return (
|
||||
<View className='flex shrink' style={{ minWidth: 60 }} {...props}>
|
||||
<PlatformDropdown
|
||||
title={t("playback_speed.title")}
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
@@ -14,14 +14,16 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
|
||||
const allPlayed = items.every((item) => item.UserData?.Played);
|
||||
const toggle = useMarkAsPlayed(items);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
void toggle(!allPlayed);
|
||||
}, [allPlayed, toggle]);
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<RoundButton
|
||||
color={allPlayed ? "purple" : "white"}
|
||||
icon={allPlayed ? "checkmark" : "checkmark"}
|
||||
onPress={async () => {
|
||||
await toggle(!allPlayed);
|
||||
}}
|
||||
onPress={handlePress}
|
||||
size={props.size}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,42 +1,266 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { ActivityIndicator, Alert, TouchableOpacity, View } from "react-native";
|
||||
import { Swipeable } from "react-native-gesture-handler";
|
||||
import { useMMKVString } from "react-native-mmkv";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import {
|
||||
deleteAccountCredential,
|
||||
getPreviousServers,
|
||||
removeServerFromList,
|
||||
type SavedServer,
|
||||
type SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { AccountsSheet } from "./AccountsSheet";
|
||||
import { Text } from "./common/Text";
|
||||
import { ListGroup } from "./list/ListGroup";
|
||||
import { ListItem } from "./list/ListItem";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
}
|
||||
import { PasswordEntryModal } from "./PasswordEntryModal";
|
||||
import { PINEntryModal } from "./PINEntryModal";
|
||||
|
||||
interface PreviousServersListProps {
|
||||
onServerSelect: (server: Server) => void;
|
||||
onServerSelect: (server: SavedServer) => void;
|
||||
onQuickLogin?: (serverUrl: string, userId: string) => Promise<void>;
|
||||
onPasswordLogin?: (
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<void>;
|
||||
onAddAccount?: (server: SavedServer) => void;
|
||||
}
|
||||
|
||||
export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
onServerSelect,
|
||||
onQuickLogin,
|
||||
onPasswordLogin,
|
||||
onAddAccount,
|
||||
}) => {
|
||||
const [_previousServers, setPreviousServers] =
|
||||
useMMKVString("previousServers");
|
||||
const [loadingServer, setLoadingServer] = useState<string | null>(null);
|
||||
|
||||
// Modal states
|
||||
const [accountsSheetOpen, setAccountsSheetOpen] = useState(false);
|
||||
const [selectedServer, setSelectedServer] = useState<SavedServer | null>(
|
||||
null,
|
||||
);
|
||||
const [pinModalVisible, setPinModalVisible] = useState(false);
|
||||
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] =
|
||||
useState<SavedServerAccount | null>(null);
|
||||
|
||||
const previousServers = useMemo(() => {
|
||||
return JSON.parse(_previousServers || "[]") as Server[];
|
||||
return JSON.parse(_previousServers || "[]") as SavedServer[];
|
||||
}, [_previousServers]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const refreshServers = () => {
|
||||
const servers = getPreviousServers();
|
||||
setPreviousServers(JSON.stringify(servers));
|
||||
};
|
||||
|
||||
const handleAccountLogin = async (
|
||||
server: SavedServer,
|
||||
account: SavedServerAccount,
|
||||
) => {
|
||||
switch (account.securityType) {
|
||||
case "none":
|
||||
// Quick login without protection
|
||||
if (onQuickLogin) {
|
||||
setLoadingServer(server.address);
|
||||
try {
|
||||
await onQuickLogin(server.address, account.userId);
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
[{ text: t("common.ok"), onPress: () => onServerSelect(server) }],
|
||||
);
|
||||
} finally {
|
||||
setLoadingServer(null);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "pin":
|
||||
// Show PIN entry modal
|
||||
setSelectedServer(server);
|
||||
setSelectedAccount(account);
|
||||
setPinModalVisible(true);
|
||||
break;
|
||||
|
||||
case "password":
|
||||
// Show password entry modal
|
||||
setSelectedServer(server);
|
||||
setSelectedAccount(account);
|
||||
setPasswordModalVisible(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerPress = async (server: SavedServer) => {
|
||||
if (loadingServer) return; // Prevent double-tap
|
||||
|
||||
const accountCount = server.accounts?.length || 0;
|
||||
|
||||
if (accountCount === 0) {
|
||||
// No saved accounts, go to manual login
|
||||
onServerSelect(server);
|
||||
} else {
|
||||
// Has accounts, show account sheet (allows adding new account too)
|
||||
setSelectedServer(server);
|
||||
setAccountsSheetOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinSuccess = async () => {
|
||||
setPinModalVisible(false);
|
||||
if (selectedServer && selectedAccount && onQuickLogin) {
|
||||
setLoadingServer(selectedServer.address);
|
||||
try {
|
||||
await onQuickLogin(selectedServer.address, selectedAccount.userId);
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t("server.session_expired"),
|
||||
t("server.please_login_again"),
|
||||
[
|
||||
{
|
||||
text: t("common.ok"),
|
||||
onPress: () => onServerSelect(selectedServer),
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
setLoadingServer(null);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (password: string) => {
|
||||
if (selectedServer && selectedAccount && onPasswordLogin) {
|
||||
await onPasswordLogin(
|
||||
selectedServer.address,
|
||||
selectedAccount.username,
|
||||
password,
|
||||
);
|
||||
setPasswordModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPIN = async () => {
|
||||
if (selectedServer && selectedAccount) {
|
||||
await deleteAccountCredential(
|
||||
selectedServer.address,
|
||||
selectedAccount.userId,
|
||||
);
|
||||
refreshServers();
|
||||
// Go to manual login
|
||||
onServerSelect(selectedServer);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFirstCredential = async (serverUrl: string) => {
|
||||
const server = previousServers.find((s) => s.address === serverUrl);
|
||||
if (!server || server.accounts.length === 0) return;
|
||||
|
||||
Alert.alert(
|
||||
t("server.remove_saved_login"),
|
||||
t("server.remove_saved_login_description"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.remove"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
// Remove first account
|
||||
await deleteAccountCredential(serverUrl, server.accounts[0].userId);
|
||||
refreshServers();
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveServer = useCallback(
|
||||
async (serverUrl: string) => {
|
||||
await removeServerFromList(serverUrl);
|
||||
refreshServers();
|
||||
},
|
||||
[setPreviousServers],
|
||||
);
|
||||
|
||||
const renderRightActions = useCallback(
|
||||
(serverUrl: string, swipeableRef: React.RefObject<Swipeable | null>) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
swipeableRef.current?.close();
|
||||
handleRemoveServer(serverUrl);
|
||||
}}
|
||||
className='bg-red-600 justify-center items-center px-5'
|
||||
>
|
||||
<Ionicons name='trash' size={20} color='white' />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
[handleRemoveServer],
|
||||
);
|
||||
|
||||
const getServerSubtitle = (server: SavedServer): string | undefined => {
|
||||
const accountCount = server.accounts?.length || 0;
|
||||
|
||||
if (accountCount > 1) {
|
||||
return t("server.accounts_count", { count: accountCount });
|
||||
}
|
||||
if (accountCount === 1) {
|
||||
return `${server.accounts[0].username} • ${t("server.saved")}`;
|
||||
}
|
||||
return server.name ? server.address : undefined;
|
||||
};
|
||||
|
||||
const getSecurityIcon = (
|
||||
server: SavedServer,
|
||||
): keyof typeof Ionicons.glyphMap | null => {
|
||||
const accountCount = server.accounts?.length || 0;
|
||||
if (accountCount === 0) return null;
|
||||
|
||||
if (accountCount > 1) {
|
||||
return "people";
|
||||
}
|
||||
|
||||
const account = server.accounts[0];
|
||||
switch (account.securityType) {
|
||||
case "pin":
|
||||
return "keypad";
|
||||
case "password":
|
||||
return "lock-closed";
|
||||
default:
|
||||
return "key";
|
||||
}
|
||||
};
|
||||
|
||||
if (!previousServers.length) return null;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("server.previous_servers")} className='mt-4'>
|
||||
{previousServers.map((s) => (
|
||||
<ListItem
|
||||
<ServerItem
|
||||
key={s.address}
|
||||
onPress={() => onServerSelect(s)}
|
||||
title={s.address}
|
||||
showArrow
|
||||
server={s}
|
||||
loadingServer={loadingServer}
|
||||
onPress={() => handleServerPress(s)}
|
||||
onRemoveCredential={() => handleRemoveFirstCredential(s.address)}
|
||||
renderRightActions={renderRightActions}
|
||||
subtitle={getServerSubtitle(s)}
|
||||
securityIcon={getSecurityIcon(s)}
|
||||
/>
|
||||
))}
|
||||
<ListItem
|
||||
@@ -47,6 +271,113 @@ export const PreviousServersList: React.FC<PreviousServersListProps> = ({
|
||||
textColor='red'
|
||||
/>
|
||||
</ListGroup>
|
||||
<Text className='text-xs text-neutral-500 mt-2 ml-4'>
|
||||
{t("server.swipe_to_remove")}
|
||||
</Text>
|
||||
|
||||
{/* Account Selection Sheet */}
|
||||
<AccountsSheet
|
||||
open={accountsSheetOpen}
|
||||
setOpen={setAccountsSheetOpen}
|
||||
server={selectedServer}
|
||||
onAccountSelect={(account) => {
|
||||
if (selectedServer) {
|
||||
handleAccountLogin(selectedServer, account);
|
||||
}
|
||||
}}
|
||||
onAddAccount={() => {
|
||||
if (selectedServer && onAddAccount) {
|
||||
onAddAccount(selectedServer);
|
||||
}
|
||||
}}
|
||||
onAccountDeleted={refreshServers}
|
||||
/>
|
||||
|
||||
{/* PIN Entry Modal */}
|
||||
<PINEntryModal
|
||||
visible={pinModalVisible}
|
||||
onClose={() => {
|
||||
setPinModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
onSuccess={handlePinSuccess}
|
||||
onForgotPIN={handleForgotPIN}
|
||||
serverUrl={selectedServer?.address || ""}
|
||||
userId={selectedAccount?.userId || ""}
|
||||
username={selectedAccount?.username || ""}
|
||||
/>
|
||||
|
||||
{/* Password Entry Modal */}
|
||||
<PasswordEntryModal
|
||||
visible={passwordModalVisible}
|
||||
onClose={() => {
|
||||
setPasswordModalVisible(false);
|
||||
setSelectedAccount(null);
|
||||
setSelectedServer(null);
|
||||
}}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
username={selectedAccount?.username || ""}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerItemProps {
|
||||
server: SavedServer;
|
||||
loadingServer: string | null;
|
||||
onPress: () => void;
|
||||
onRemoveCredential: () => void;
|
||||
renderRightActions: (
|
||||
serverUrl: string,
|
||||
swipeableRef: React.RefObject<Swipeable | null>,
|
||||
) => React.ReactNode;
|
||||
subtitle?: string;
|
||||
securityIcon: keyof typeof Ionicons.glyphMap | null;
|
||||
}
|
||||
|
||||
const ServerItem: React.FC<ServerItemProps> = ({
|
||||
server,
|
||||
loadingServer,
|
||||
onPress,
|
||||
onRemoveCredential,
|
||||
renderRightActions,
|
||||
subtitle,
|
||||
securityIcon,
|
||||
}) => {
|
||||
const swipeableRef = useRef<Swipeable>(null);
|
||||
const hasAccounts = server.accounts?.length > 0;
|
||||
|
||||
return (
|
||||
<Swipeable
|
||||
ref={swipeableRef}
|
||||
renderRightActions={() =>
|
||||
renderRightActions(server.address, swipeableRef)
|
||||
}
|
||||
overshootRight={false}
|
||||
>
|
||||
<ListItem
|
||||
onPress={onPress}
|
||||
title={server.name || server.address}
|
||||
subtitle={subtitle}
|
||||
showArrow={loadingServer !== server.address}
|
||||
disabled={loadingServer === server.address}
|
||||
>
|
||||
{loadingServer === server.address ? (
|
||||
<ActivityIndicator size='small' color={Colors.primary} />
|
||||
) : hasAccounts && securityIcon ? (
|
||||
<TouchableOpacity
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveCredential();
|
||||
}}
|
||||
className='p-1'
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name={securityIcon} size={16} color={Colors.primary} />
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</ListItem>
|
||||
</Swipeable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type {
|
||||
@@ -55,23 +55,23 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const JellyserrRatings: React.FC<{
|
||||
export const SeerrRatings: React.FC<{
|
||||
result: MovieResult | TvResult | TvDetails | MovieDetails;
|
||||
}> = ({ result }) => {
|
||||
const { jellyseerrApi, getMediaType } = useJellyseerr();
|
||||
const { seerrApi, getMediaType } = useSeerr();
|
||||
|
||||
const mediaType = useMemo(() => getMediaType(result), [result]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
|
||||
queryKey: ["seerr", result.id, mediaType, "ratings"],
|
||||
queryFn: async () => {
|
||||
return mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieRatings(result.id)
|
||||
: jellyseerrApi?.tvRatings(result.id);
|
||||
? seerrApi?.movieRatings(result.id)
|
||||
: seerrApi?.tvRatings(result.id);
|
||||
},
|
||||
staleTime: (5).minutesToMilliseconds(),
|
||||
retry: false,
|
||||
enabled: !!jellyseerrApi,
|
||||
enabled: !!seerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Platform, TouchableOpacity, type ViewProps } from "react-native";
|
||||
import { Platform, type ViewProps } from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
@@ -38,7 +39,7 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...(viewProps as any)}
|
||||
@@ -51,13 +52,13 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
if (fillColor)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...(viewProps as any)}
|
||||
@@ -70,12 +71,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (background === false)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
{...(viewProps as any)}
|
||||
@@ -88,12 +89,12 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
if (Platform.OS === "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
className={`rounded-full ${buttonSize} flex items-center justify-center ${
|
||||
fillColor ? fillColorClass : "bg-transparent"
|
||||
@@ -104,15 +105,15 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
<Ionicons
|
||||
name={icon}
|
||||
size={size === "large" ? 22 : 18}
|
||||
color={"white"}
|
||||
color={color === "white" ? "white" : "#9334E9"}
|
||||
/>
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={handlePress} {...(viewProps as any)}>
|
||||
<Pressable onPress={handlePress} {...(viewProps as any)}>
|
||||
<BlurView
|
||||
intensity={90}
|
||||
className={`rounded-full overflow-hidden ${buttonSize} flex items-center justify-center ${fillColorClass}`}
|
||||
@@ -127,6 +128,6 @@ export const RoundButton: React.FC<PropsWithChildren<Props>> = ({
|
||||
) : null}
|
||||
{children ? children : null}
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
252
components/SaveAccountModal.tsx
Normal file
252
components/SaveAccountModal.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
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 { AccountSecurityType } from "@/utils/secureCredentials";
|
||||
import { Button } from "./Button";
|
||||
import { Text } from "./common/Text";
|
||||
import { PinInput } from "./inputs/PinInput";
|
||||
|
||||
interface SaveAccountModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (securityType: AccountSecurityType, pinCode?: string) => void;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface SecurityOption {
|
||||
type: AccountSecurityType;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
}
|
||||
|
||||
const SECURITY_OPTIONS: SecurityOption[] = [
|
||||
{
|
||||
type: "none",
|
||||
titleKey: "save_account.no_protection",
|
||||
descriptionKey: "save_account.no_protection_desc",
|
||||
icon: "flash-outline",
|
||||
},
|
||||
{
|
||||
type: "pin",
|
||||
titleKey: "save_account.pin_code",
|
||||
descriptionKey: "save_account.pin_code_desc",
|
||||
icon: "keypad-outline",
|
||||
},
|
||||
{
|
||||
type: "password",
|
||||
titleKey: "save_account.password",
|
||||
descriptionKey: "save_account.password_desc",
|
||||
icon: "lock-closed-outline",
|
||||
},
|
||||
];
|
||||
|
||||
export const SaveAccountModal: React.FC<SaveAccountModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSave,
|
||||
username,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [selectedType, setSelectedType] = useState<AccountSecurityType>("none");
|
||||
const [pinCode, setPinCode] = useState("");
|
||||
const [pinError, setPinError] = useState<string | null>(null);
|
||||
|
||||
const isAndroid = Platform.OS === "android";
|
||||
const snapPoints = useMemo(
|
||||
() => (isAndroid ? ["100%"] : ["70%"]),
|
||||
[isAndroid],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setSelectedType("none");
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
};
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOptionSelect = (type: AccountSecurityType) => {
|
||||
setSelectedType(type);
|
||||
setPinCode("");
|
||||
setPinError(null);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (selectedType === "pin") {
|
||||
if (pinCode.length !== 4) {
|
||||
setPinError(t("pin.enter_4_digits") || "Enter 4 digits");
|
||||
return;
|
||||
}
|
||||
onSave("pin", pinCode);
|
||||
} else {
|
||||
onSave(selectedType);
|
||||
}
|
||||
resetState();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canSave = () => {
|
||||
if (selectedType === "pin") {
|
||||
return pinCode.length === 4;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
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={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: Math.max(16, insets.bottom),
|
||||
}}
|
||||
>
|
||||
<View className='flex-1'>
|
||||
{/* Header */}
|
||||
<View className='mb-4'>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("save_account.title")}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 mt-1'>{username}</Text>
|
||||
</View>
|
||||
|
||||
{/* PIN Entry Step */}
|
||||
{selectedType === "pin" ? (
|
||||
<View className='flex-1'>
|
||||
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 mb-4'>
|
||||
<Text className='text-neutral-100 text-center text-lg mb-4'>
|
||||
{t("pin.setup_pin")}
|
||||
</Text>
|
||||
<PinInput
|
||||
value={pinCode}
|
||||
onChangeText={setPinCode}
|
||||
length={4}
|
||||
style={{ paddingHorizontal: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
{pinError && (
|
||||
<Text className='text-red-500 text-center mt-3'>
|
||||
{pinError}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
/* Security Options */
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-400 mb-3'>
|
||||
{t("save_account.security_option")}
|
||||
</Text>
|
||||
<View className='bg-neutral-800 rounded-xl overflow-hidden'>
|
||||
{SECURITY_OPTIONS.map((option, index) => (
|
||||
<TouchableOpacity
|
||||
key={option.type}
|
||||
onPress={() => handleOptionSelect(option.type)}
|
||||
className={`flex-row items-center p-4 ${
|
||||
index < SECURITY_OPTIONS.length - 1
|
||||
? "border-b border-neutral-700"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<View className='w-10 h-10 bg-neutral-700 rounded-full items-center justify-center mr-3'>
|
||||
<Ionicons name={option.icon} size={20} color='white' />
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text className='text-neutral-100 font-medium'>
|
||||
{t(option.titleKey)}
|
||||
</Text>
|
||||
<Text className='text-neutral-400 text-sm'>
|
||||
{t(option.descriptionKey)}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`w-6 h-6 rounded-full border-2 items-center justify-center ${
|
||||
selectedType === option.type
|
||||
? "border-purple-500 bg-purple-500"
|
||||
: "border-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{selectedType === option.type && (
|
||||
<Ionicons name='checkmark' size={14} color='white' />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<View className='flex-row gap-3 mt-4'>
|
||||
<Button onPress={handleCancel} color='black' className='flex-1'>
|
||||
{t("save_account.cancel_button")}
|
||||
</Button>
|
||||
<Button
|
||||
onPress={handleSave}
|
||||
color='purple'
|
||||
className='flex-1'
|
||||
disabled={!canSave()}
|
||||
>
|
||||
{t("save_account.save_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,10 @@ import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
import { HorizontalScroll } from "./common/HorizontalScroll";
|
||||
import { Text } from "./common/Text";
|
||||
import { TouchableItemRouter } from "./common/TouchableItemRouter";
|
||||
@@ -53,7 +57,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
|
||||
<HorizontalScroll
|
||||
data={movies}
|
||||
loading={isLoading}
|
||||
height={247}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
noItemsText={t("item_card.no_similar_items_found")}
|
||||
renderItem={(item: BaseItemDto, idx: number) => (
|
||||
<TouchableItemRouter
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
@@ -282,7 +282,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
|
||||
if (currentItem) {
|
||||
setSelectedOptions({
|
||||
bitrate: defaultBitrate,
|
||||
mediaSource: defaultMediaSource,
|
||||
mediaSource: defaultMediaSource ?? undefined,
|
||||
subtitleIndex: defaultSubtitleIndex ?? -1,
|
||||
audioIndex: defaultAudioIndex,
|
||||
});
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||
import { useRouter } from "expo-router";
|
||||
import {
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
type TouchableOpacityProps,
|
||||
} from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
interface Props extends BlurViewProps {
|
||||
background?: "blur" | "transparent";
|
||||
touchableOpacityProps?: TouchableOpacityProps;
|
||||
pressableProps?: Omit<PressableProps, "onPress">;
|
||||
}
|
||||
|
||||
export const HeaderBackButton: React.FC<Props> = ({
|
||||
background = "transparent",
|
||||
touchableOpacityProps,
|
||||
pressableProps,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className='flex items-center justify-center w-9 h-9'
|
||||
{...touchableOpacityProps}
|
||||
{...pressableProps}
|
||||
>
|
||||
<Ionicons name='arrow-back' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
if (background === "transparent" && Platform.OS !== "android")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
{...touchableOpacityProps}
|
||||
>
|
||||
<Pressable onPress={() => router.back()} {...pressableProps}>
|
||||
<BlurView
|
||||
{...props}
|
||||
intensity={100}
|
||||
@@ -49,14 +43,14 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
color='white'
|
||||
/>
|
||||
</BlurView>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className=' rounded-full p-2'
|
||||
{...touchableOpacityProps}
|
||||
{...pressableProps}
|
||||
>
|
||||
<Ionicons
|
||||
className='drop-shadow-2xl'
|
||||
@@ -64,6 +58,6 @@ export const HeaderBackButton: React.FC<Props> = ({
|
||||
size={24}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -58,7 +58,7 @@ export const HorizontalScroll = <T,>(
|
||||
|
||||
if (!data || loading) {
|
||||
return (
|
||||
<View className='px-4 mb-2'>
|
||||
<View className='px-4'>
|
||||
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
|
||||
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
|
||||
</View>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Image } from "expo-image";
|
||||
import { View } from "react-native";
|
||||
|
||||
export const LargePoster: React.FC<{ url?: string | null }> = ({ url }) => {
|
||||
if (!url)
|
||||
return (
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<View className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800' />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='p-4 rounded-xl overflow-hidden '>
|
||||
<Image
|
||||
source={{ uri: url }}
|
||||
className='w-full aspect-video rounded-xl overflow-hidden border border-neutral-800'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
42
components/common/SectionHeader.tsx
Normal file
42
components/common/SectionHeader.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { Text } from "./Text";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
actionLabel?: string;
|
||||
actionDisabled?: boolean;
|
||||
onPressAction?: () => void;
|
||||
};
|
||||
|
||||
export const SectionHeader: React.FC<Props> = ({
|
||||
title,
|
||||
actionLabel,
|
||||
actionDisabled = false,
|
||||
onPressAction,
|
||||
}) => {
|
||||
const shouldShowAction = Boolean(actionLabel) && Boolean(onPressAction);
|
||||
|
||||
return (
|
||||
<View className='px-4 flex flex-row items-center justify-between mb-2'>
|
||||
<Text className='text-lg font-bold text-neutral-100'>{title}</Text>
|
||||
{shouldShowAction && (
|
||||
<TouchableOpacity
|
||||
onPress={onPressAction}
|
||||
disabled={actionDisabled}
|
||||
accessibilityRole='button'
|
||||
accessibilityLabel={actionLabel}
|
||||
className='py-1 pl-3'
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: actionDisabled ? "rgba(255,255,255,0.4)" : Colors.primary,
|
||||
}}
|
||||
>
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
@@ -20,7 +21,7 @@ interface Props extends TouchableOpacityProps {
|
||||
mediaType: MediaType;
|
||||
}
|
||||
|
||||
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
export const TouchableSeerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
result,
|
||||
mediaTitle,
|
||||
releaseYear,
|
||||
@@ -42,7 +43,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
if (!result) return;
|
||||
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/page`,
|
||||
// @ts-expect-error
|
||||
params: {
|
||||
...result,
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
@@ -16,6 +18,10 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
return `/(auth)/(tabs)/${from}/livetv`;
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return `/(auth)/(tabs)/(libraries)/music/${item.Id}`;
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
|
||||
}
|
||||
@@ -50,6 +56,13 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
return {
|
||||
pathname: "/music/[libraryId]" as const,
|
||||
params: { libraryId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Series") {
|
||||
return {
|
||||
pathname: "/series/[id]" as const,
|
||||
@@ -71,13 +84,49 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
|
||||
if (item.Type === "CollectionFolder") {
|
||||
return {
|
||||
pathname: "/[libraryId]" as const,
|
||||
params: { libraryId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
// Music types - use shared routes for proper back navigation
|
||||
if (item.Type === "MusicArtist") {
|
||||
return {
|
||||
pathname: "/music/artist/[artistId]" as const,
|
||||
params: { artistId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "MusicAlbum") {
|
||||
return {
|
||||
pathname: "/music/album/[albumId]" as const,
|
||||
params: { albumId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Audio") {
|
||||
// Navigate to the album if available, otherwise to the item page
|
||||
if (item.AlbumId) {
|
||||
return {
|
||||
pathname: "/music/album/[albumId]" as const,
|
||||
params: { albumId: item.AlbumId },
|
||||
};
|
||||
}
|
||||
return {
|
||||
pathname: "/items/page" as const,
|
||||
params: { id: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
if (item.Type === "Playlist") {
|
||||
return {
|
||||
pathname: "/music/playlist/[playlistId]" as const,
|
||||
params: { playlistId: item.Id! },
|
||||
};
|
||||
}
|
||||
|
||||
// Default case - items page
|
||||
return {
|
||||
pathname: "/items/page" as const,
|
||||
@@ -87,18 +136,31 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// Force music libraries to navigate via the explicit string route.
|
||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
router.push(itemRouter(item, from) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}, [from, item, router]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (
|
||||
!(
|
||||
@@ -108,18 +170,24 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
)
|
||||
)
|
||||
return;
|
||||
const options = [
|
||||
|
||||
const options: string[] = [
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = 3;
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
@@ -128,31 +196,32 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [showActionSheetWithOptions, isFavorite, markAsPlayedStatus]);
|
||||
}, [
|
||||
showActionSheetWithOptions,
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
]);
|
||||
|
||||
if (
|
||||
from === "(home)" ||
|
||||
from === "(search)" ||
|
||||
from === "(libraries)" ||
|
||||
from === "(favorites)"
|
||||
from === "(favorites)" ||
|
||||
from === "(watchlists)"
|
||||
)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onLongPress={showActionSheet}
|
||||
onPress={() => {
|
||||
if (isOffline) {
|
||||
// For offline mode, we still need to use query params
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}}
|
||||
onPress={handlePress}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={{
|
||||
width: "32%",
|
||||
}}
|
||||
className='flex flex-col'
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
className='w-full bg-neutral-800 mb-2 rounded-lg'
|
||||
/>
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-1' />
|
||||
<View className='h-2 bg-neutral-800 rounded-full mb-2 w-1/2' />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -12,6 +10,8 @@ import {
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||
import { JobStatus } from "@/providers/Downloads/types";
|
||||
@@ -37,7 +37,7 @@ interface DownloadCardProps extends TouchableOpacityProps {
|
||||
export const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
|
||||
const { cancelDownload } = useDownload();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
|
||||
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
onLongPress={showActionSheet}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item}>
|
||||
{base64Image ? (
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { deleteItems } = useDownload();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/series/[id]",
|
||||
params: { id: items[0].SeriesId!, offline: "true" },
|
||||
})
|
||||
}
|
||||
onLongPress={showActionSheet}
|
||||
>
|
||||
{base64Image ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import {
|
||||
filterByAtom,
|
||||
genreFilterAtom,
|
||||
tagsFilterAtom,
|
||||
yearFilterAtom,
|
||||
@@ -13,11 +14,13 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||
const [selectedFilters, setSelectedFilters] = useAtom(filterByAtom);
|
||||
|
||||
if (
|
||||
selectedGenres.length === 0 &&
|
||||
selectedTags.length === 0 &&
|
||||
selectedYears.length === 0
|
||||
selectedYears.length === 0 &&
|
||||
selectedFilters.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -28,6 +31,7 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
|
||||
setSelectedGenres([]);
|
||||
setSelectedTags([]);
|
||||
setSelectedYears([]);
|
||||
setSelectedFilters([]);
|
||||
}}
|
||||
className='bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1'
|
||||
{...props}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Text, View } from "react-native";
|
||||
// PNG ASSET
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
||||
|
||||
@@ -22,8 +23,10 @@ type FavoriteTypes =
|
||||
type EmptyState = Record<FavoriteTypes, boolean>;
|
||||
|
||||
export const Favorites = () => {
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const pageSize = 20;
|
||||
const [emptyState, setEmptyState] = useState<EmptyState>({
|
||||
Series: false,
|
||||
Movie: false,
|
||||
@@ -91,35 +94,77 @@ export const Favorites = () => {
|
||||
|
||||
const fetchFavoriteSeries = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Series", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Series", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteMovies = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Movie", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Movie", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteEpisodes = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Episode", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Episode", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteVideos = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Video", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Video", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoriteBoxsets = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("BoxSet", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("BoxSet", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
const fetchFavoritePlaylists = useCallback(
|
||||
({ pageParam }: { pageParam: number }) =>
|
||||
fetchFavoritesByType("Playlist", pageParam),
|
||||
[fetchFavoritesByType],
|
||||
fetchFavoritesByType("Playlist", pageParam, pageSize),
|
||||
[fetchFavoritesByType, pageSize],
|
||||
);
|
||||
|
||||
const handleSeeAllSeries = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Series", title: t("favorites.series") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllMovies = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Movie", title: t("favorites.movies") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllEpisodes = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Episode", title: t("favorites.episodes") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllVideos = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Video", title: t("favorites.videos") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllBoxsets = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "BoxSet", title: t("favorites.boxsets") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllPlaylists = useCallback(() => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(favorites)/see-all",
|
||||
params: { type: "Playlist", title: t("favorites.playlists") },
|
||||
} as any);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<View className='flex flex-co gap-y-4'>
|
||||
{areAllEmpty() && (
|
||||
@@ -143,6 +188,8 @@ export const Favorites = () => {
|
||||
queryKey={["home", "favorites", "series"]}
|
||||
title={t("favorites.series")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllSeries}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteMovies}
|
||||
@@ -150,30 +197,40 @@ export const Favorites = () => {
|
||||
title={t("favorites.movies")}
|
||||
hideIfEmpty
|
||||
orientation='vertical'
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllMovies}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteEpisodes}
|
||||
queryKey={["home", "favorites", "episodes"]}
|
||||
title={t("favorites.episodes")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllEpisodes}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteVideos}
|
||||
queryKey={["home", "favorites", "videos"]}
|
||||
title={t("favorites.videos")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllVideos}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoriteBoxsets}
|
||||
queryKey={["home", "favorites", "boxsets"]}
|
||||
title={t("favorites.boxsets")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllBoxsets}
|
||||
/>
|
||||
<InfiniteScrollingCollectionList
|
||||
queryFn={fetchFavoritePlaylists}
|
||||
queryKey={["home", "favorites", "playlists"]}
|
||||
title={t("favorites.playlists")}
|
||||
hideIfEmpty
|
||||
pageSize={pageSize}
|
||||
onPressSeeAll={handleSeeAllPlaylists}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -21,22 +21,28 @@ import {
|
||||
Platform,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Pressable } from "react-native-gesture-handler";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useIntroSheet } from "@/providers/IntroSheetProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { eventBus } from "@/utils/eventBus";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
type InfiniteScrollingCollectionListSection = {
|
||||
type: "InfiniteScrollingCollectionList";
|
||||
@@ -45,12 +51,15 @@ type InfiniteScrollingCollectionListSection = {
|
||||
queryFn: QueryFunction<BaseItemDto[], any, number>;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
pageSize?: number;
|
||||
priority?: 1 | 2; // 1 = high priority (loads first), 2 = low priority
|
||||
parentId?: string; // Library ID for "See All" navigation
|
||||
};
|
||||
|
||||
type MediaListSectionType = {
|
||||
type: "MediaListSection";
|
||||
queryKey: (string | undefined)[];
|
||||
queryFn: QueryFunction<BaseItemDto>;
|
||||
priority?: 1 | 2;
|
||||
};
|
||||
|
||||
type Section = InfiniteScrollingCollectionListSection | MediaListSectionType;
|
||||
@@ -74,7 +83,22 @@ export const Home = () => {
|
||||
retryCheck,
|
||||
} = useNetworkStatus();
|
||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
||||
const { showIntro } = useIntroSheet();
|
||||
|
||||
// Show intro modal on first launch
|
||||
useEffect(() => {
|
||||
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||
if (!hasShownIntro) {
|
||||
const timer = setTimeout(() => {
|
||||
showIntro();
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [showIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && !prevIsConnected.current) {
|
||||
@@ -97,7 +121,7 @@ export const Home = () => {
|
||||
}
|
||||
navigation.setOptions({
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push("/(auth)/downloads");
|
||||
}}
|
||||
@@ -109,7 +133,7 @@ export const Home = () => {
|
||||
color={hasDownloads ? Colors.primary : "white"}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
),
|
||||
});
|
||||
}, [navigation, router, hasDownloads]);
|
||||
@@ -172,6 +196,7 @@ export const Home = () => {
|
||||
|
||||
const refetch = async () => {
|
||||
setLoading(true);
|
||||
setLoadedSections(new Set());
|
||||
await refreshStreamyfinPluginSettings();
|
||||
await invalidateCache();
|
||||
setLoading(false);
|
||||
@@ -194,10 +219,10 @@ export const Home = () => {
|
||||
(
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
limit: 100, // Fetch a larger set for pagination
|
||||
fields: ["PrimaryImageAspectRatio", "Path", "Genres"],
|
||||
limit: 10,
|
||||
fields: ["PrimaryImageAspectRatio"],
|
||||
imageTypeLimit: 1,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes,
|
||||
parentId,
|
||||
})
|
||||
@@ -208,6 +233,7 @@ export const Home = () => {
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
pageSize,
|
||||
parentId,
|
||||
}),
|
||||
[api, user?.Id],
|
||||
);
|
||||
@@ -236,64 +262,143 @@ export const Home = () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to sort items by most recent activity
|
||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
return items.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to deduplicate items by ID
|
||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (!item.Id || seen.has(item.Id)) return false;
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Build the first sections based on merge setting
|
||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||
? [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
queryKey: ["home", "continueAndNextUp"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
// Fetch both in parallel
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
// Combine, sort by recent activity, deduplicate
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = sortByRecentActivity(combined);
|
||||
const deduplicated = deduplicateById(sorted);
|
||||
|
||||
// Paginate client-side
|
||||
return deduplicated.slice(pageParam, pageParam + 10);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...latestMediaViews,
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
pageSize: 10,
|
||||
},
|
||||
...firstSections,
|
||||
...latestMediaViews.map((s) => ({ ...s, priority: 2 as const })),
|
||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||
...(!settings?.streamyStatsMovieRecommendations
|
||||
? [
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
priority: 2 as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
@@ -322,10 +427,9 @@ export const Home = () => {
|
||||
if (section.nextUp) {
|
||||
const response = await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: section.nextUp?.limit || pageSize,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
enableResumable: section.nextUp?.enableResumable,
|
||||
enableRewatching: section.nextUp?.enableRewatching,
|
||||
});
|
||||
@@ -338,7 +442,7 @@ export const Home = () => {
|
||||
await getUserLibraryApi(api).getLatestMedia({
|
||||
userId: user?.Id,
|
||||
includeItemTypes: section.latest?.includeItemTypes,
|
||||
limit: section.latest?.limit || 100, // Fetch larger set
|
||||
limit: section.latest?.limit || 10,
|
||||
isPlayed: section.latest?.isPlayed,
|
||||
groupItems: section.latest?.groupItems,
|
||||
})
|
||||
@@ -367,6 +471,8 @@ export const Home = () => {
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: section?.orientation || "vertical",
|
||||
pageSize,
|
||||
// First 2 custom sections are high priority
|
||||
priority: index < 2 ? 1 : 2,
|
||||
});
|
||||
});
|
||||
return ss;
|
||||
@@ -374,6 +480,25 @@ export const Home = () => {
|
||||
|
||||
const sections = settings?.home?.sections ? customSections : defaultSections;
|
||||
|
||||
// Get all high priority section keys and check if all have loaded
|
||||
const highPrioritySectionKeys = useMemo(() => {
|
||||
return sections
|
||||
.filter((s) => s.priority === 1)
|
||||
.map((s) => s.queryKey.join("-"));
|
||||
}, [sections]);
|
||||
|
||||
const allHighPriorityLoaded = useMemo(() => {
|
||||
return highPrioritySectionKeys.every((key) => loadedSections.has(key));
|
||||
}, [highPrioritySectionKeys, loadedSections]);
|
||||
|
||||
const markSectionLoaded = useCallback(
|
||||
(queryKey: (string | undefined | null)[]) => {
|
||||
const key = queryKey.join("-");
|
||||
setLoadedSections((prev) => new Set(prev).add(key));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (!isConnected || serverConnected !== true) {
|
||||
let title = "";
|
||||
let subtitle = "";
|
||||
@@ -451,10 +576,6 @@ export const Home = () => {
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
onScroll={(event) => {
|
||||
setScrollY(event.nativeEvent.contentOffset.y - 500);
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={loading}
|
||||
@@ -474,28 +595,90 @@ export const Home = () => {
|
||||
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
||||
<View
|
||||
key='streamystats-sections'
|
||||
className='flex flex-col space-y-4'
|
||||
>
|
||||
{settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
enabled={allHighPriorityLoaded}
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
enabled={allHighPriorityLoaded}
|
||||
/>
|
||||
)}
|
||||
{settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists
|
||||
enabled={allHighPriorityLoaded}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
) : null;
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
const isHighPriority = section.priority === 1;
|
||||
const handleSeeAll = section.parentId
|
||||
? () => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(libraries)/[libraryId]",
|
||||
params: {
|
||||
libraryId: section.parentId!,
|
||||
sortBy: SortByOption.DateCreated,
|
||||
sortOrder: SortOrderOption.Descending,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
enabled={isHighPriority || allHighPriorityLoaded}
|
||||
onLoaded={
|
||||
isHighPriority
|
||||
? () => markSectionLoaded(section.queryKey)
|
||||
: undefined
|
||||
}
|
||||
onPressSeeAll={handleSeeAll}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -30,9 +30,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { InfiniteScrollingCollectionList } from "@/components/home/InfiniteScrollingCollectionList";
|
||||
import { StreamystatsPromotedWatchlists } from "@/components/home/StreamystatsPromotedWatchlists";
|
||||
import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecommendations";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
@@ -241,64 +244,143 @@ export const HomeWithCarousel = () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to sort items by most recent activity
|
||||
const sortByRecentActivity = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
return items.sort((a, b) => {
|
||||
const dateA = a.UserData?.LastPlayedDate || a.DateCreated || "";
|
||||
const dateB = b.UserData?.LastPlayedDate || b.DateCreated || "";
|
||||
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to deduplicate items by ID
|
||||
const deduplicateById = (items: BaseItemDto[]): BaseItemDto[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (!item.Id || seen.has(item.Id)) return false;
|
||||
seen.add(item.Id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// Build the first sections based on merge setting
|
||||
const firstSections: Section[] = settings.mergeNextUpAndContinueWatching
|
||||
? [
|
||||
{
|
||||
title: t("home.continue_and_next_up"),
|
||||
queryKey: ["home", "continueAndNextUp"],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
// Fetch both in parallel
|
||||
const [resumeResponse, nextUpResponse] = await Promise.all([
|
||||
getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
}),
|
||||
getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: 0,
|
||||
limit: 20,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const resumeItems = resumeResponse.data.Items || [];
|
||||
const nextUpItems = nextUpResponse.data.Items || [];
|
||||
|
||||
// Combine, sort by recent activity, deduplicate
|
||||
const combined = [...resumeItems, ...nextUpItems];
|
||||
const sorted = sortByRecentActivity(combined);
|
||||
const deduplicated = deduplicateById(sorted);
|
||||
|
||||
// Paginate client-side
|
||||
return deduplicated.slice(pageParam, pageParam + 10);
|
||||
},
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
];
|
||||
|
||||
const ss: Section[] = [
|
||||
{
|
||||
title: t("home.continue_watching"),
|
||||
queryKey: ["home", "resumeItems"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getItemsApi(api).getResumeItems({
|
||||
userId: user.Id,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
||||
fields: ["Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
{
|
||||
title: t("home.next_up"),
|
||||
queryKey: ["home", "nextUp-all"],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getTvShowsApi(api).getNextUp({
|
||||
userId: user?.Id,
|
||||
fields: ["MediaSourceCount", "Genres"],
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb", "Logo"],
|
||||
enableResumable: false,
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "horizontal",
|
||||
pageSize: 10,
|
||||
},
|
||||
...firstSections,
|
||||
...latestMediaViews,
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList",
|
||||
orientation: "vertical",
|
||||
pageSize: 10,
|
||||
},
|
||||
// Only show Jellyfin suggested movies if StreamyStats recommendations are disabled
|
||||
...(!settings?.streamyStatsMovieRecommendations
|
||||
? [
|
||||
{
|
||||
title: t("home.suggested_movies"),
|
||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
||||
queryFn: async ({ pageParam = 0 }: { pageParam?: number }) =>
|
||||
(
|
||||
await getSuggestionsApi(api).getSuggestions({
|
||||
userId: user?.Id,
|
||||
startIndex: pageParam,
|
||||
limit: 10,
|
||||
mediaType: ["Video"],
|
||||
type: ["Movie"],
|
||||
})
|
||||
).data.Items || [],
|
||||
type: "InfiniteScrollingCollectionList" as const,
|
||||
orientation: "vertical" as const,
|
||||
pageSize: 10,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return ss;
|
||||
}, [api, user?.Id, collections, t, createCollectionConfig]);
|
||||
}, [
|
||||
api,
|
||||
user?.Id,
|
||||
collections,
|
||||
t,
|
||||
createCollectionConfig,
|
||||
settings?.streamyStatsMovieRecommendations,
|
||||
settings.mergeNextUpAndContinueWatching,
|
||||
]);
|
||||
|
||||
const customSections = useMemo(() => {
|
||||
if (!api || !user?.Id || !settings?.home?.sections) return [];
|
||||
@@ -477,28 +559,68 @@ export const HomeWithCarousel = () => {
|
||||
>
|
||||
<View className='flex flex-col space-y-4'>
|
||||
{sections.map((section, index) => {
|
||||
// Render Streamystats sections after Continue Watching and Next Up
|
||||
// When merged, they appear after index 0; otherwise after index 1
|
||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
||||
? 0
|
||||
: 1;
|
||||
const hasStreamystatsContent =
|
||||
settings.streamyStatsMovieRecommendations ||
|
||||
settings.streamyStatsSeriesRecommendations ||
|
||||
settings.streamyStatsPromotedWatchlists;
|
||||
const streamystatsSections =
|
||||
index === streamystatsIndex && hasStreamystatsContent
|
||||
? [
|
||||
settings.streamyStatsMovieRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
key='movie-recommendations'
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_movies",
|
||||
)}
|
||||
type='Movie'
|
||||
/>
|
||||
),
|
||||
settings.streamyStatsSeriesRecommendations && (
|
||||
<StreamystatsRecommendations
|
||||
key='series-recommendations'
|
||||
title={t(
|
||||
"home.settings.plugins.streamystats.recommended_series",
|
||||
)}
|
||||
type='Series'
|
||||
/>
|
||||
),
|
||||
settings.streamyStatsPromotedWatchlists && (
|
||||
<StreamystatsPromotedWatchlists key='promoted-watchlists' />
|
||||
),
|
||||
].filter(Boolean)
|
||||
: null;
|
||||
|
||||
if (section.type === "InfiniteScrollingCollectionList") {
|
||||
return (
|
||||
<InfiniteScrollingCollectionList
|
||||
key={index}
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<InfiniteScrollingCollectionList
|
||||
title={section.title}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
orientation={section.orientation}
|
||||
hideIfEmpty
|
||||
pageSize={section.pageSize}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (section.type === "MediaListSection") {
|
||||
return (
|
||||
<MediaListSection
|
||||
key={index}
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
<View key={index} className='flex flex-col space-y-4'>
|
||||
<MediaListSection
|
||||
queryKey={section.queryKey}
|
||||
queryFn={section.queryFn}
|
||||
scrollY={scrollY}
|
||||
enableLazyLoading={true}
|
||||
/>
|
||||
{streamystatsSections}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type QueryKey,
|
||||
useInfiniteQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
View,
|
||||
type ViewProps,
|
||||
} from "react-native";
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { Colors } from "../../constants/Colors";
|
||||
@@ -28,6 +29,9 @@ interface Props extends ViewProps {
|
||||
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
|
||||
hideIfEmpty?: boolean;
|
||||
pageSize?: number;
|
||||
onPressSeeAll?: () => void;
|
||||
enabled?: boolean;
|
||||
onLoaded?: () => void;
|
||||
}
|
||||
|
||||
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
@@ -38,32 +42,67 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
pageSize = 10,
|
||||
onPressSeeAll,
|
||||
enabled = true,
|
||||
onLoaded,
|
||||
...props
|
||||
}) => {
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than pageSize, we've reached the end
|
||||
if (lastPage.length < pageSize) {
|
||||
return undefined;
|
||||
}
|
||||
// Otherwise, return the next start index
|
||||
return allPages.length * pageSize;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
const effectivePageSize = Math.max(1, pageSize);
|
||||
const hasCalledOnLoaded = useRef(false);
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isSuccess,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKey,
|
||||
queryFn: ({ pageParam = 0, ...context }) =>
|
||||
queryFn({ ...context, queryKey, pageParam }),
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// If the last page has fewer items than pageSize, we've reached the end
|
||||
if (lastPage.length < effectivePageSize) {
|
||||
return undefined;
|
||||
}
|
||||
// Otherwise, return the next start index based on how many items we already loaded.
|
||||
// This avoids overlaps if the server/page size differs from our configured page size.
|
||||
return allPages.reduce((acc, page) => acc + page.length, 0);
|
||||
},
|
||||
initialPageParam: 0,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Notify parent when data has loaded
|
||||
useEffect(() => {
|
||||
if (isSuccess && !hasCalledOnLoaded.current && onLoaded) {
|
||||
hasCalledOnLoaded.current = true;
|
||||
onLoaded();
|
||||
}
|
||||
}, [isSuccess, onLoaded]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Flatten all pages into a single array
|
||||
const allItems = data?.pages.flat() || [];
|
||||
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
|
||||
const allItems = useMemo(() => {
|
||||
const items = data?.pages.flat() ?? [];
|
||||
const seen = new Set<string>();
|
||||
const deduped: BaseItemDto[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const id = item.Id;
|
||||
if (!id) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(item);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}, [data]);
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
const itemWidth = orientation === "horizontal" ? 184 : 120; // w-44 (176px) + mr-2 (8px) or w-28 (112px) + mr-2 (8px)
|
||||
@@ -90,9 +129,12 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
|
||||
{title}
|
||||
</Text>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
|
||||
actionDisabled={isLoading}
|
||||
onPressAction={onPressSeeAll}
|
||||
/>
|
||||
{isLoading === false && allItems.length === 0 && (
|
||||
<View className='px-4'>
|
||||
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
|
||||
@@ -136,11 +178,11 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{allItems.map((item) => (
|
||||
{allItems.map((item, index) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className={`mr-2
|
||||
key={`${item.Id}-${index}`}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Dimensions, View, type ViewProps } from "react-native";
|
||||
@@ -16,6 +16,7 @@ import Carousel, {
|
||||
type ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
@@ -21,7 +21,6 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
260
components/home/StreamystatsPromotedWatchlists.tsx
Normal file
260
components/home/StreamystatsPromotedWatchlists.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
PublicSystemInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type { StreamystatsWatchlist } from "@/utils/streamystats/types";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
|
||||
|
||||
interface WatchlistSectionProps extends ViewProps {
|
||||
watchlist: StreamystatsWatchlist;
|
||||
jellyfinServerId: string;
|
||||
}
|
||||
|
||||
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||
watchlist,
|
||||
jellyfinServerId,
|
||||
...props
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: items, isLoading } = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"watchlist",
|
||||
watchlist.id,
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||
if (!settings?.streamyStatsServerUrl || !api?.accessToken || !user?.Id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const watchlistDetail = await streamystatsApi.getWatchlistItemIds({
|
||||
watchlistId: watchlist.id,
|
||||
jellyfinServerId,
|
||||
});
|
||||
|
||||
const itemIds = watchlistDetail.data?.items;
|
||||
if (!itemIds?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
ids: itemIds,
|
||||
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
Boolean(settings?.streamyStatsServerUrl) &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
|
||||
}, [items]);
|
||||
|
||||
const handleSeeAll = () => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(watchlists)/[watchlistId]",
|
||||
params: { watchlistId: watchlist.id.toString() },
|
||||
} as any);
|
||||
};
|
||||
|
||||
if (!isLoading && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<SectionHeader
|
||||
title={watchlist.name}
|
||||
actionLabel={t("common.seeAll", { defaultValue: "See all" })}
|
||||
actionDisabled={isLoading}
|
||||
onPressAction={handleSeeAll}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<View className='flex flex-row gap-2 px-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-28' key={i}>
|
||||
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{items?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='mr-2 w-28'
|
||||
>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const StreamystatsPromotedWatchlists: React.FC<
|
||||
StreamystatsPromotedWatchlistsProps
|
||||
> = ({ enabled = true, ...props }) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const streamyStatsEnabled = useMemo(() => {
|
||||
return Boolean(settings?.streamyStatsServerUrl);
|
||||
}, [settings?.streamyStatsServerUrl]);
|
||||
|
||||
// Fetch server info to get the Jellyfin server ID
|
||||
const { data: serverInfo } = useQuery({
|
||||
queryKey: ["jellyfin", "serverInfo"],
|
||||
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||
if (!api) return null;
|
||||
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||
return response.data;
|
||||
},
|
||||
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const jellyfinServerId = serverInfo?.Id;
|
||||
|
||||
const {
|
||||
data: watchlists,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"promotedWatchlists",
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<StreamystatsWatchlist[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!jellyfinServerId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamystatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamystatsApi.getPromotedWatchlists({
|
||||
jellyfinServerId,
|
||||
includePreview: false,
|
||||
});
|
||||
|
||||
return response.data || [];
|
||||
},
|
||||
enabled:
|
||||
enabled &&
|
||||
streamyStatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!streamyStatsEnabled) return null;
|
||||
if (isError) return null;
|
||||
if (!isLoading && (!watchlists || watchlists.length === 0)) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View {...props}>
|
||||
<View className='h-4 w-32 bg-neutral-900 rounded ml-4 mb-2' />
|
||||
<View className='flex flex-row gap-2 px-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-28' key={i}>
|
||||
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Loading...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
{watchlists?.map((watchlist) => (
|
||||
<WatchlistSection
|
||||
key={watchlist.id}
|
||||
watchlist={watchlist}
|
||||
jellyfinServerId={jellyfinServerId!}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
197
components/home/StreamystatsRecommendations.tsx
Normal file
197
components/home/StreamystatsRecommendations.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
PublicSystemInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi, getSystemApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, View, type ViewProps } from "react-native";
|
||||
|
||||
import { SectionHeader } from "@/components/common/SectionHeader";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import MoviePoster from "@/components/posters/MoviePoster";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { createStreamystatsApi } from "@/utils/streamystats/api";
|
||||
import type { StreamystatsRecommendationsIdsResponse } from "@/utils/streamystats/types";
|
||||
import { TouchableItemRouter } from "../common/TouchableItemRouter";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import SeriesPoster from "../posters/SeriesPoster";
|
||||
|
||||
const ITEM_WIDTH = 120; // w-28 (112px) + mr-2 (8px)
|
||||
|
||||
interface Props extends ViewProps {
|
||||
title: string;
|
||||
type: "Movie" | "Series";
|
||||
limit?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const StreamystatsRecommendations: React.FC<Props> = ({
|
||||
title,
|
||||
type,
|
||||
limit = 20,
|
||||
enabled = true,
|
||||
...props
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const user = useAtomValue(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const streamyStatsEnabled = useMemo(() => {
|
||||
return Boolean(settings?.streamyStatsServerUrl);
|
||||
}, [settings?.streamyStatsServerUrl]);
|
||||
|
||||
// Fetch server info to get the Jellyfin server ID
|
||||
const { data: serverInfo } = useQuery({
|
||||
queryKey: ["jellyfin", "serverInfo"],
|
||||
queryFn: async (): Promise<PublicSystemInfo | null> => {
|
||||
if (!api) return null;
|
||||
const response = await getSystemApi(api).getPublicSystemInfo();
|
||||
return response.data;
|
||||
},
|
||||
enabled: enabled && Boolean(api) && streamyStatsEnabled,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour - server info rarely changes
|
||||
});
|
||||
|
||||
const jellyfinServerId = serverInfo?.Id;
|
||||
|
||||
const {
|
||||
data: recommendationIds,
|
||||
isLoading: isLoadingRecommendations,
|
||||
isError: isRecommendationsError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"recommendations",
|
||||
type,
|
||||
jellyfinServerId,
|
||||
settings?.streamyStatsServerUrl,
|
||||
],
|
||||
queryFn: async (): Promise<string[]> => {
|
||||
if (
|
||||
!settings?.streamyStatsServerUrl ||
|
||||
!api?.accessToken ||
|
||||
!jellyfinServerId
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const streamyStatsApi = createStreamystatsApi({
|
||||
serverUrl: settings.streamyStatsServerUrl,
|
||||
jellyfinToken: api.accessToken,
|
||||
});
|
||||
|
||||
const response = await streamyStatsApi.getRecommendationIds(
|
||||
jellyfinServerId,
|
||||
type,
|
||||
limit,
|
||||
);
|
||||
|
||||
const data = response as StreamystatsRecommendationsIdsResponse;
|
||||
|
||||
if (type === "Movie") {
|
||||
return data.data.movies || [];
|
||||
}
|
||||
return data.data.series || [];
|
||||
},
|
||||
enabled:
|
||||
enabled &&
|
||||
streamyStatsEnabled &&
|
||||
Boolean(api?.accessToken) &&
|
||||
Boolean(jellyfinServerId) &&
|
||||
Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const {
|
||||
data: items,
|
||||
isLoading: isLoadingItems,
|
||||
isError: isItemsError,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"streamystats",
|
||||
"recommendations",
|
||||
"items",
|
||||
type,
|
||||
recommendationIds,
|
||||
],
|
||||
queryFn: async (): Promise<BaseItemDto[]> => {
|
||||
if (!api || !user?.Id || !recommendationIds?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
ids: recommendationIds,
|
||||
fields: ["PrimaryImageAspectRatio", "Genres"],
|
||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled:
|
||||
Boolean(recommendationIds?.length) && Boolean(api) && Boolean(user?.Id),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const isLoading = isLoadingRecommendations || isLoadingItems;
|
||||
const isError = isRecommendationsError || isItemsError;
|
||||
|
||||
const snapOffsets = useMemo(() => {
|
||||
return items?.map((_, index) => index * ITEM_WIDTH) ?? [];
|
||||
}, [items]);
|
||||
|
||||
if (!streamyStatsEnabled) return null;
|
||||
if (isError) return null;
|
||||
if (!isLoading && (!items || items.length === 0)) return null;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<SectionHeader title={title} />
|
||||
{isLoading ? (
|
||||
<View className='flex flex-row gap-2 px-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<View className='w-28' key={i}>
|
||||
<View className='bg-neutral-900 aspect-[2/3] w-full rounded-md mb-1' />
|
||||
<View className='rounded-md overflow-hidden mb-1 self-start'>
|
||||
<Text
|
||||
className='text-neutral-900 bg-neutral-900 rounded-md'
|
||||
numberOfLines={1}
|
||||
>
|
||||
Loading title...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate='fast'
|
||||
>
|
||||
<View className='px-4 flex flex-row'>
|
||||
{items?.map((item) => (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
className='mr-2 w-28'
|
||||
>
|
||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||
{item.Type === "Series" && <SeriesPoster item={item} />}
|
||||
<ItemCardText item={item} />
|
||||
</TouchableItemRouter>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
77
components/item/ItemPeopleSections.tsx
Normal file
77
components/item/ItemPeopleSections.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { InteractionManager, View, type ViewProps } from "react-native";
|
||||
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
}
|
||||
|
||||
export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOffline) return;
|
||||
const task = InteractionManager.runAfterInteractions(() =>
|
||||
setEnabled(true),
|
||||
);
|
||||
return () => task.cancel();
|
||||
}, [isOffline]);
|
||||
|
||||
const { data, isLoading } = useItemPeopleQuery(
|
||||
item.Id,
|
||||
enabled && !isOffline,
|
||||
);
|
||||
|
||||
const people = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
||||
|
||||
const itemWithPeople = useMemo(() => {
|
||||
return { ...item, People: people } as BaseItemDto;
|
||||
}, [item, people]);
|
||||
|
||||
const topPeople = useMemo(() => people.slice(0, 3), [people]);
|
||||
|
||||
const renderActorSection = useCallback(
|
||||
(person: BaseItemPerson, idx: number, total: number) => {
|
||||
if (!person.Id) return null;
|
||||
|
||||
const spacingClassName = idx === total - 1 ? undefined : "mb-2";
|
||||
|
||||
return (
|
||||
<MoreMoviesWithActor
|
||||
key={person.Id}
|
||||
currentItem={item}
|
||||
actorId={person.Id}
|
||||
actorName={person.Name}
|
||||
className={spacingClassName}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[item],
|
||||
);
|
||||
|
||||
if (isOffline || !enabled) return null;
|
||||
|
||||
const shouldSpaceCastAndCrew = topPeople.length > 0;
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
<CastAndCrew
|
||||
item={itemWithPeople}
|
||||
loading={isLoading}
|
||||
className={shouldSpaceCastAndCrew ? "mb-2" : undefined}
|
||||
/>
|
||||
{topPeople.map((person, idx) =>
|
||||
renderActorSection(person, idx, topPeople.length),
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -63,6 +63,10 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
_itemType = "Series";
|
||||
} else if (library.CollectionType === "boxsets") {
|
||||
_itemType = "BoxSet";
|
||||
} else if (library.CollectionType === "homevideos") {
|
||||
_itemType = "Video";
|
||||
} else if (library.CollectionType === "musicvideos") {
|
||||
_itemType = "MusicVideo";
|
||||
}
|
||||
|
||||
return _itemType;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Text } from "../common/Text";
|
||||
interface Props extends ViewProps {
|
||||
title?: string | null | undefined;
|
||||
subtitle?: string | null | undefined;
|
||||
subtitleColor?: "default" | "red";
|
||||
value?: string | null | undefined;
|
||||
children?: ReactNode;
|
||||
iconAfter?: ReactNode;
|
||||
@@ -14,6 +15,7 @@ interface Props extends ViewProps {
|
||||
textColor?: "default" | "blue" | "red";
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
disabledByAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
@@ -27,21 +29,23 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
textColor = "default",
|
||||
onPress,
|
||||
disabled = false,
|
||||
disabledByAdmin = false,
|
||||
...viewProps
|
||||
}) => {
|
||||
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
|
||||
const isDisabled = disabled || disabledByAdmin;
|
||||
if (onPress)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
onPress={onPress}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...(viewProps as any)}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -54,14 +58,13 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
);
|
||||
return (
|
||||
<View
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${
|
||||
disabled ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`flex flex-row items-center justify-between bg-neutral-900 min-h-[42px] py-2 pr-4 pl-4 ${isDisabled ? "opacity-50" : ""}`}
|
||||
{...viewProps}
|
||||
>
|
||||
<ListItemContent
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
subtitle={effectiveSubtitle}
|
||||
subtitleColor={disabledByAdmin ? "red" : undefined}
|
||||
value={value}
|
||||
icon={icon}
|
||||
textColor={textColor}
|
||||
@@ -77,6 +80,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
|
||||
const ListItemContent = ({
|
||||
title,
|
||||
subtitle,
|
||||
subtitleColor,
|
||||
textColor,
|
||||
icon,
|
||||
value,
|
||||
@@ -107,7 +111,7 @@ const ListItemContent = ({
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
className='text-[#9899A1] text-[12px] mt-0.5'
|
||||
className={`text-[12px] mt-0.5 ${subtitleColor === "red" ? "text-red-600" : "text-[#9899A1]"}`}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
98
components/music/AnimatedEqualizer.tsx
Normal file
98
components/music/AnimatedEqualizer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface Props {
|
||||
color?: string;
|
||||
barWidth?: number;
|
||||
barCount?: number;
|
||||
height?: number;
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.35;
|
||||
const MAX_SCALE = 1;
|
||||
const DURATIONS = [800, 650, 750];
|
||||
const DELAYS = [0, 200, 100];
|
||||
|
||||
const Bar: React.FC<{
|
||||
color: string;
|
||||
barWidth: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
}> = ({ color, barWidth, height, duration, delay }) => {
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withDelay(
|
||||
delay,
|
||||
withRepeat(
|
||||
withTiming(1, { duration, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
scaleY: interpolate(progress.value, [0, 1], [MIN_SCALE, MAX_SCALE]),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width: barWidth,
|
||||
height,
|
||||
backgroundColor: color,
|
||||
borderRadius: 1,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnimatedEqualizer: React.FC<Props> = ({
|
||||
color = "#9334E9",
|
||||
barWidth = 3,
|
||||
barCount = 3,
|
||||
height = 12,
|
||||
gap = 2,
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
height,
|
||||
gap,
|
||||
marginRight: 6,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: barCount }).map((_, index) => (
|
||||
<Bar
|
||||
key={index}
|
||||
color={color}
|
||||
barWidth={barWidth}
|
||||
height={height}
|
||||
duration={DURATIONS[index % DURATIONS.length]}
|
||||
delay={DELAYS[index % DELAYS.length]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
157
components/music/CreatePlaylistModal.tsx
Normal file
157
components/music/CreatePlaylistModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onPlaylistCreated?: (playlistId: string) => void;
|
||||
initialTrackId?: string;
|
||||
}
|
||||
|
||||
export const CreatePlaylistModal: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
onPlaylistCreated,
|
||||
initialTrackId,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const createPlaylist = useCreatePlaylist();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const snapPoints = useMemo(() => ["40%"], []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const result = await createPlaylist.mutateAsync({
|
||||
name: name.trim(),
|
||||
trackIds: initialTrackId ? [initialTrackId] : undefined,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
onPlaylistCreated?.(result);
|
||||
}
|
||||
setOpen(false);
|
||||
}, [name, createPlaylist, initialTrackId, onPlaylistCreated, setOpen]);
|
||||
|
||||
const isValid = name.trim().length > 0;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
keyboardBehavior='interactive'
|
||||
keyboardBlurBehavior='restore'
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-6'>
|
||||
{t("music.playlists.create_playlist")}
|
||||
</Text>
|
||||
|
||||
<Text className='text-neutral-400 mb-2 text-sm'>
|
||||
{t("music.playlists.playlist_name")}
|
||||
</Text>
|
||||
<BottomSheetTextInput
|
||||
placeholder={t("music.playlists.enter_name")}
|
||||
placeholderTextColor='#737373'
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
returnKeyType='done'
|
||||
onSubmitEditing={handleCreate}
|
||||
style={{
|
||||
backgroundColor: "#262626",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
fontSize: 16,
|
||||
color: "white",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={handleCreate}
|
||||
disabled={!isValid || createPlaylist.isPending}
|
||||
className={`py-4 rounded-xl ${isValid ? "bg-purple-600" : "bg-neutral-700"}`}
|
||||
>
|
||||
{createPlaylist.isPending ? (
|
||||
<ActivityIndicator color='white' />
|
||||
) : (
|
||||
<Text
|
||||
className={`text-center font-semibold ${isValid ? "text-white" : "text-neutral-500"}`}
|
||||
>
|
||||
{t("music.playlists.create")}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
359
components/music/MiniPlayerBar.tsx
Normal file
359
components/music/MiniPlayerBar.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { GlassEffectView } from "react-native-glass-effect-view";
|
||||
import Animated, {
|
||||
Easing,
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
const HORIZONTAL_MARGIN = Platform.OS === "android" ? 12 : 20;
|
||||
const BOTTOM_TAB_HEIGHT = Platform.OS === "android" ? 56 : 52;
|
||||
const BAR_HEIGHT = Platform.OS === "android" ? 58 : 50;
|
||||
|
||||
// Gesture thresholds
|
||||
const VELOCITY_THRESHOLD = 1000;
|
||||
|
||||
// Logarithmic slowdown - never stops, just gets progressively slower
|
||||
const rubberBand = (distance: number, scale: number = 8): number => {
|
||||
"worklet";
|
||||
const absDistance = Math.abs(distance);
|
||||
const sign = distance < 0 ? -1 : 1;
|
||||
// Logarithmic: keeps growing but slower and slower
|
||||
return sign * scale * Math.log(1 + absDistance / scale);
|
||||
};
|
||||
|
||||
export const MiniPlayerBar: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const {
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
isLoading,
|
||||
progress,
|
||||
duration,
|
||||
togglePlayPause,
|
||||
next,
|
||||
stop,
|
||||
} = useMusicPlayer();
|
||||
|
||||
// Gesture state
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!api || !currentTrack) return null;
|
||||
const albumId = currentTrack.AlbumId || currentTrack.ParentId;
|
||||
if (albumId) {
|
||||
return `${api.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
}
|
||||
return `${api.basePath}/Items/${currentTrack.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
}, [api, currentTrack]);
|
||||
|
||||
const _progressPercentage = useMemo(() => {
|
||||
if (!duration || duration === 0) return 0;
|
||||
return (progress / duration) * 100;
|
||||
}, [progress, duration]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push("/(auth)/now-playing");
|
||||
}, [router]);
|
||||
|
||||
const handlePlayPause = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
},
|
||||
[togglePlayPause],
|
||||
);
|
||||
|
||||
const handleNext = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
},
|
||||
[next],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
// Pan gesture for swipe up (open modal) and swipe down (dismiss)
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetY([-15, 15])
|
||||
.onUpdate((event) => {
|
||||
// Logarithmic slowdown - keeps moving but progressively slower
|
||||
translateY.value = rubberBand(event.translationY, 6);
|
||||
})
|
||||
.onEnd((event) => {
|
||||
const velocity = event.velocityY;
|
||||
const currentPosition = translateY.value;
|
||||
|
||||
// Swipe up - open modal (check position OR velocity)
|
||||
if (currentPosition < -16 || velocity < -VELOCITY_THRESHOLD) {
|
||||
// Slow return animation - won't jank with navigation
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
runOnJS(handlePress)();
|
||||
return;
|
||||
}
|
||||
// Swipe down - stop playback and dismiss (check position OR velocity)
|
||||
if (currentPosition > 16 || velocity > VELOCITY_THRESHOLD) {
|
||||
// No need to reset - component will unmount
|
||||
runOnJS(handleDismiss)();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only animate back if no action was triggered
|
||||
translateY.value = withTiming(0, {
|
||||
duration: 200,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
});
|
||||
|
||||
// Animated styles for the container
|
||||
const animatedContainerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
// Animated styles for the inner bar
|
||||
const animatedBarStyle = useAnimatedStyle(() => ({
|
||||
height: interpolate(
|
||||
translateY.value,
|
||||
[-50, 0, 50],
|
||||
[BAR_HEIGHT + 12, BAR_HEIGHT, BAR_HEIGHT],
|
||||
Extrapolation.EXTEND,
|
||||
),
|
||||
opacity: interpolate(
|
||||
translateY.value,
|
||||
[0, 30],
|
||||
[1, 0.6],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}));
|
||||
|
||||
if (!currentTrack) return null;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Tappable area: Album art + Track info */}
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
style={styles.tappableArea}
|
||||
>
|
||||
{/* Album art */}
|
||||
<View style={styles.albumArt}>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={styles.albumImage}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.albumPlaceholder}>
|
||||
<Ionicons name='musical-note' size={20} color='#888' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Track info */}
|
||||
<View style={styles.trackInfo}>
|
||||
<Text numberOfLines={1} style={styles.trackTitle}>
|
||||
{currentTrack.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.artistName}>
|
||||
{currentTrack.Artists?.join(", ") || currentTrack.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Controls */}
|
||||
<View style={styles.controls}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color='white' style={styles.loader} />
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayPause}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
style={styles.controlButton}
|
||||
>
|
||||
<Ionicons
|
||||
name={isPlaying ? "pause" : "play"}
|
||||
size={26}
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleNext}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
style={styles.controlButton}
|
||||
>
|
||||
<Ionicons name='play-forward' size={22} color='white' />
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Progress bar at bottom */}
|
||||
{/* <View style={styles.progressContainer}>
|
||||
<View
|
||||
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
||||
/>
|
||||
</View> */}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
bottom:
|
||||
BOTTOM_TAB_HEIGHT +
|
||||
insets.bottom +
|
||||
(Platform.OS === "android" ? 32 : 4),
|
||||
},
|
||||
animatedContainerStyle,
|
||||
]}
|
||||
>
|
||||
<Animated.View style={[styles.touchable, animatedBarStyle]}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<GlassEffectView style={styles.blurContainer}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: 10,
|
||||
paddingLeft: 20,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
</GlassEffectView>
|
||||
) : (
|
||||
<View style={styles.androidContainer}>{content}</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: HORIZONTAL_MARGIN,
|
||||
right: HORIZONTAL_MARGIN,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
touchable: {
|
||||
borderRadius: 50,
|
||||
overflow: "hidden",
|
||||
},
|
||||
blurContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
androidContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: "rgba(28, 28, 30, 0.97)",
|
||||
borderRadius: 14,
|
||||
borderWidth: 0.5,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
tappableArea: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
albumArt: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#333",
|
||||
},
|
||||
albumImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
albumPlaceholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#2a2a2a",
|
||||
},
|
||||
trackInfo: {
|
||||
flex: 1,
|
||||
marginLeft: 12,
|
||||
marginRight: 8,
|
||||
justifyContent: "center",
|
||||
},
|
||||
trackTitle: {
|
||||
color: "white",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
artistName: {
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
fontSize: 12,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
controlButton: {
|
||||
padding: 8,
|
||||
},
|
||||
loader: {
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
progressContainer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 10,
|
||||
right: 10,
|
||||
height: 3,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
progressFill: {
|
||||
height: "100%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
});
|
||||
65
components/music/MusicAlbumCard.tsx
Normal file
65
components/music/MusicAlbumCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
album: BaseItemDto;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: album }),
|
||||
[api, album],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push(`/music/album/${album.Id}`);
|
||||
}, [router, album.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
style={{ width }}
|
||||
className='flex flex-col'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
height: width,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-4xl'>🎵</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text numberOfLines={1} className='text-white text-sm font-medium mt-2'>
|
||||
{album.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-xs'>
|
||||
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
67
components/music/MusicAlbumRowCard.tsx
Normal file
67
components/music/MusicAlbumRowCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
album: BaseItemDto;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE = 56;
|
||||
|
||||
export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: album }),
|
||||
[api, album],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push(`/music/album/${album.Id}`);
|
||||
}, [router, album.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className='flex-row items-center py-2'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: IMAGE_SIZE,
|
||||
height: IMAGE_SIZE,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-2xl'>🎵</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1 ml-3'>
|
||||
<Text numberOfLines={1} className='text-white text-base font-medium'>
|
||||
{album.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
|
||||
{album.AlbumArtist || album.Artists?.join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
66
components/music/MusicArtistCard.tsx
Normal file
66
components/music/MusicArtistCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
artist: BaseItemDto;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE = 48;
|
||||
|
||||
export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: artist }),
|
||||
[api, artist],
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push(`/music/artist/${artist.Id}`);
|
||||
}, [router, artist.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className='flex-row items-center py-2'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: IMAGE_SIZE,
|
||||
height: IMAGE_SIZE,
|
||||
borderRadius: IMAGE_SIZE / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-xl'>👤</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className='text-white text-base font-medium ml-3 flex-1'
|
||||
>
|
||||
{artist.Name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
222
components/music/MusicPlaybackEngine.tsx
Normal file
222
components/music/MusicPlaybackEngine.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import TrackPlayer, {
|
||||
Event,
|
||||
type PlaybackActiveTrackChangedEvent,
|
||||
State,
|
||||
useActiveTrack,
|
||||
usePlaybackState,
|
||||
useProgress,
|
||||
} from "react-native-track-player";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
deleteTrack,
|
||||
getLocalPath,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
export const MusicPlaybackEngine: React.FC = () => {
|
||||
const { position, duration } = useProgress(1000);
|
||||
const playbackState = usePlaybackState();
|
||||
const activeTrack = useActiveTrack();
|
||||
const {
|
||||
setProgress,
|
||||
setDuration,
|
||||
setIsPlaying,
|
||||
reportProgress,
|
||||
onTrackEnd,
|
||||
syncFromTrackPlayer,
|
||||
triggerLookahead,
|
||||
} = useMusicPlayer();
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
|
||||
// Sync progress from TrackPlayer to our state
|
||||
useEffect(() => {
|
||||
if (position > 0) {
|
||||
setProgress(position);
|
||||
}
|
||||
}, [position, setProgress]);
|
||||
|
||||
// Sync duration from TrackPlayer to our state
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
setDuration(duration);
|
||||
}
|
||||
}, [duration, setDuration]);
|
||||
|
||||
// Sync playback state from TrackPlayer to our state
|
||||
useEffect(() => {
|
||||
const isPlaying = playbackState.state === State.Playing;
|
||||
setIsPlaying(isPlaying);
|
||||
}, [playbackState.state, setIsPlaying]);
|
||||
|
||||
// Sync active track changes
|
||||
useEffect(() => {
|
||||
if (activeTrack) {
|
||||
syncFromTrackPlayer();
|
||||
}
|
||||
}, [activeTrack?.id, syncFromTrackPlayer]);
|
||||
|
||||
// Report progress every ~10 seconds
|
||||
useEffect(() => {
|
||||
if (
|
||||
Math.floor(position) - Math.floor(lastReportedProgressRef.current) >=
|
||||
10
|
||||
) {
|
||||
lastReportedProgressRef.current = position;
|
||||
reportProgress();
|
||||
}
|
||||
}, [position, reportProgress]);
|
||||
|
||||
// Listen for track changes (native -> JS)
|
||||
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
||||
useEffect(() => {
|
||||
const subscription =
|
||||
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
async (event) => {
|
||||
// Trigger look-ahead caching when a new track starts playing
|
||||
if (event.track) {
|
||||
triggerLookahead();
|
||||
|
||||
// Check if there's a cached version we should use instead
|
||||
const trackId = event.track.id;
|
||||
const currentUrl = event.track.url as string;
|
||||
|
||||
// Only check if currently using a remote URL
|
||||
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||
const cachedPath = getLocalPath(trackId);
|
||||
if (cachedPath) {
|
||||
console.log(
|
||||
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||
);
|
||||
try {
|
||||
// Load the cached version, preserving position if any
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const track = queue[currentIndex];
|
||||
// Remove and re-add with cached URL
|
||||
await TrackPlayer.remove(currentIndex);
|
||||
await TrackPlayer.add(
|
||||
{ ...track, url: cachedPath },
|
||||
currentIndex,
|
||||
);
|
||||
await TrackPlayer.skip(currentIndex);
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AudioCache] Failed to switch to cached version:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no next track and the previous track ended, call onTrackEnd
|
||||
if (event.lastTrack && !event.track) {
|
||||
onTrackEnd();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onTrackEnd, triggerLookahead]);
|
||||
|
||||
// Listen for audio cache download completion and update queue URLs
|
||||
useEffect(() => {
|
||||
const onComplete = async ({
|
||||
itemId,
|
||||
localPath,
|
||||
}: {
|
||||
itemId: string;
|
||||
localPath: string;
|
||||
}) => {
|
||||
console.log(`[AudioCache] Track ${itemId} cached successfully`);
|
||||
|
||||
try {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
|
||||
// Find the track in the queue
|
||||
const trackIndex = queue.findIndex((t) => t.id === itemId);
|
||||
|
||||
// Only update if track is in queue and not currently playing
|
||||
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
||||
const track = queue[trackIndex];
|
||||
const localUrl = localPath.startsWith("file://")
|
||||
? localPath
|
||||
: `file://${localPath}`;
|
||||
|
||||
// Skip if already using local URL
|
||||
if (track.url === localUrl) return;
|
||||
|
||||
console.log(
|
||||
`[AudioCache] Updating queue track ${trackIndex} to use cached file`,
|
||||
);
|
||||
|
||||
// Remove old track and insert updated one at same position
|
||||
await TrackPlayer.remove(trackIndex);
|
||||
await TrackPlayer.add({ ...track, url: localUrl }, trackIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AudioCache] Failed to update queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", onComplete);
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for playback errors (corrupted cache files)
|
||||
useEffect(() => {
|
||||
const subscription = TrackPlayer.addEventListener(
|
||||
Event.PlaybackError,
|
||||
async (event) => {
|
||||
const activeTrack = await TrackPlayer.getActiveTrack();
|
||||
if (!activeTrack?.url) return;
|
||||
|
||||
// Only handle local file errors
|
||||
const url = activeTrack.url as string;
|
||||
if (!url.startsWith("file://")) return;
|
||||
|
||||
console.warn(
|
||||
`[MusicPlayer] Playback error for cached file: ${activeTrack.id}`,
|
||||
event,
|
||||
);
|
||||
|
||||
// Delete corrupted cache file
|
||||
if (activeTrack.id) {
|
||||
try {
|
||||
await deleteTrack(activeTrack.id);
|
||||
console.log(
|
||||
`[MusicPlayer] Deleted corrupted cache file: ${activeTrack.id}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[MusicPlayer] Failed to delete corrupted file:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip to next track
|
||||
try {
|
||||
await TrackPlayer.skipToNext();
|
||||
} catch {
|
||||
// No next track available, stop playback
|
||||
await TrackPlayer.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
// No visual component needed - TrackPlayer is headless
|
||||
return null;
|
||||
};
|
||||
117
components/music/MusicPlaylistCard.tsx
Normal file
117
components/music/MusicPlaylistCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { getLocalPath } from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
playlist: BaseItemDto;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE = 56;
|
||||
|
||||
export const MusicPlaylistCard: React.FC<Props> = ({ playlist }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
|
||||
const imageUrl = useMemo(
|
||||
() => getPrimaryImageUrl({ api, item: playlist }),
|
||||
[api, playlist],
|
||||
);
|
||||
|
||||
// Fetch playlist tracks to check download status
|
||||
const { data: tracks } = useQuery({
|
||||
queryKey: ["playlist-tracks-status", playlist.Id, user?.Id],
|
||||
queryFn: async () => {
|
||||
const response = await getItemsApi(api!).getItems({
|
||||
userId: user?.Id,
|
||||
parentId: playlist.Id,
|
||||
fields: ["MediaSources"],
|
||||
});
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!playlist.Id,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Calculate download status
|
||||
const downloadStatus = useMemo(() => {
|
||||
if (!tracks || tracks.length === 0) {
|
||||
return { downloaded: 0, total: playlist.ChildCount || 0 };
|
||||
}
|
||||
const downloaded = tracks.filter(
|
||||
(track) => !!getLocalPath(track.Id),
|
||||
).length;
|
||||
return { downloaded, total: tracks.length };
|
||||
}, [tracks, playlist.ChildCount]);
|
||||
|
||||
const allDownloaded =
|
||||
downloadStatus.total > 0 &&
|
||||
downloadStatus.downloaded === downloadStatus.total;
|
||||
const hasDownloads = downloadStatus.downloaded > 0;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
router.push(`/music/playlist/${playlist.Id}`);
|
||||
}, [router, playlist.Id]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className='flex-row items-center py-2'
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: IMAGE_SIZE,
|
||||
height: IMAGE_SIZE,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Text className='text-2xl'>🎶</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1 ml-3'>
|
||||
<Text numberOfLines={1} className='text-white text-base font-medium'>
|
||||
{playlist.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
|
||||
{playlist.ChildCount} tracks
|
||||
</Text>
|
||||
</View>
|
||||
{/* Download status indicator */}
|
||||
{allDownloaded ? (
|
||||
<Ionicons
|
||||
name='checkmark-circle'
|
||||
size={18}
|
||||
color='#22c55e'
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
) : hasDownloads ? (
|
||||
<Text className='text-neutral-500 text-xs mr-1'>
|
||||
{downloadStatus.downloaded}/{downloadStatus.total}
|
||||
</Text>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
226
components/music/MusicTrackItem.tsx
Normal file
226
components/music/MusicTrackItem.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { AnimatedEqualizer } from "@/components/music/AnimatedEqualizer";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
getLocalPath,
|
||||
isCached,
|
||||
isPermanentDownloading,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { formatDuration } from "@/utils/time";
|
||||
|
||||
interface Props {
|
||||
track: BaseItemDto;
|
||||
index?: number;
|
||||
queue?: BaseItemDto[];
|
||||
showArtwork?: boolean;
|
||||
onOptionsPress?: (track: BaseItemDto) => void;
|
||||
}
|
||||
|
||||
export const MusicTrackItem: React.FC<Props> = ({
|
||||
track,
|
||||
index: _index,
|
||||
queue,
|
||||
showArtwork = true,
|
||||
onOptionsPress,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { playTrack, currentTrack, isPlaying, loadingTrackId } =
|
||||
useMusicPlayer();
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
const haptic = useHaptic("light");
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
if (albumId) {
|
||||
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
}
|
||||
return getPrimaryImageUrl({ api, item: track });
|
||||
}, [api, track]);
|
||||
|
||||
const isCurrentTrack = currentTrack?.Id === track.Id;
|
||||
const isTrackLoading = loadingTrackId === track.Id;
|
||||
|
||||
// Track download status with reactivity to completion events
|
||||
const [downloadStatus, setDownloadStatus] = useState<
|
||||
"none" | "downloading" | "downloaded" | "cached"
|
||||
>(() => {
|
||||
if (isPermanentlyDownloaded(track.Id)) return "downloaded";
|
||||
if (isPermanentDownloading(track.Id)) return "downloading";
|
||||
if (isCached(track.Id)) return "cached";
|
||||
return "none";
|
||||
});
|
||||
|
||||
// Listen for download completion/error events
|
||||
useEffect(() => {
|
||||
const onComplete = (event: { itemId: string; permanent: boolean }) => {
|
||||
if (event.itemId === track.Id) {
|
||||
setDownloadStatus(event.permanent ? "downloaded" : "cached");
|
||||
}
|
||||
};
|
||||
const onError = (event: { itemId: string }) => {
|
||||
if (event.itemId === track.Id) {
|
||||
setDownloadStatus("none");
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", onComplete);
|
||||
audioStorageEvents.on("error", onError);
|
||||
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
audioStorageEvents.off("error", onError);
|
||||
};
|
||||
}, [track.Id]);
|
||||
|
||||
// Re-check status when track changes (for list item recycling)
|
||||
useEffect(() => {
|
||||
if (isPermanentlyDownloaded(track.Id)) {
|
||||
setDownloadStatus("downloaded");
|
||||
} else if (isPermanentDownloading(track.Id)) {
|
||||
setDownloadStatus("downloading");
|
||||
} else if (isCached(track.Id)) {
|
||||
setDownloadStatus("cached");
|
||||
} else {
|
||||
setDownloadStatus("none");
|
||||
}
|
||||
}, [track.Id]);
|
||||
|
||||
const _isDownloaded = downloadStatus === "downloaded";
|
||||
// Check if available locally (either cached or permanently downloaded)
|
||||
const isAvailableLocally = !!getLocalPath(track.Id);
|
||||
// Consider offline if either no network connection OR server is unreachable
|
||||
const isOffline = !isConnected || serverConnected === false;
|
||||
const isUnavailableOffline = isOffline && !isAvailableLocally;
|
||||
|
||||
const duration = useMemo(() => {
|
||||
if (!track.RunTimeTicks) return "";
|
||||
return formatDuration(track.RunTimeTicks);
|
||||
}, [track.RunTimeTicks]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isUnavailableOffline) return;
|
||||
playTrack(track, queue);
|
||||
}, [playTrack, track, queue, isUnavailableOffline]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
onOptionsPress?.(track);
|
||||
}, [onOptionsPress, track]);
|
||||
|
||||
const handleOptionsPress = useCallback(() => {
|
||||
haptic();
|
||||
onOptionsPress?.(track);
|
||||
}, [haptic, onOptionsPress, track]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
disabled={isUnavailableOffline}
|
||||
className={`flex-row items-center py-1.5 pl-4 pr-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
|
||||
style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
{/* Album artwork */}
|
||||
{showArtwork && (
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='musical-note' size={18} color='#737373' />
|
||||
</View>
|
||||
)}
|
||||
{isTrackLoading && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size='small' color='white' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Track info */}
|
||||
<View className='flex-1 mr-3'>
|
||||
<View className='flex-row items-center'>
|
||||
{isCurrentTrack && isPlaying && <AnimatedEqualizer />}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`flex-1 text-sm ${isCurrentTrack ? "text-purple-400 font-medium" : "text-white"}`}
|
||||
>
|
||||
{track.Name}
|
||||
</Text>
|
||||
</View>
|
||||
<Text numberOfLines={1} className='text-neutral-500 text-xs mt-0.5'>
|
||||
{track.Artists?.join(", ") || track.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Download/cache status indicator */}
|
||||
{downloadStatus === "downloading" && (
|
||||
<ActivityIndicator
|
||||
size={14}
|
||||
color='#9334E9'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
{downloadStatus === "downloaded" && (
|
||||
<Ionicons
|
||||
name='checkmark-circle'
|
||||
size={14}
|
||||
color='#22c55e'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
<Text className='text-neutral-500 text-xs'>{duration}</Text>
|
||||
|
||||
{/* Options button */}
|
||||
{onOptionsPress && (
|
||||
<TouchableOpacity
|
||||
onPress={handleOptionsPress}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
className='pl-3 py-1'
|
||||
>
|
||||
<Ionicons name='ellipsis-vertical' size={16} color='#737373' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
136
components/music/PlaylistOptionsSheet.tsx
Normal file
136
components/music/PlaylistOptionsSheet.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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, 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";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
playlist: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const PlaylistOptionsSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
playlist,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const deletePlaylist = useDeletePlaylist();
|
||||
|
||||
const snapPoints = useMemo(() => ["25%"], []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDeletePlaylist = useCallback(() => {
|
||||
if (!playlist?.Id) return;
|
||||
|
||||
Alert.alert(
|
||||
t("music.playlists.delete_playlist"),
|
||||
t("music.playlists.delete_confirm", { name: playlist.Name }),
|
||||
[
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
deletePlaylist.mutate(
|
||||
{ playlistId: playlist.Id! },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
router.back();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}, [playlist, deletePlaylist, setOpen, router, t]);
|
||||
|
||||
if (!playlist) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||
<TouchableOpacity
|
||||
onPress={handleDeletePlaylist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||
<Text className='text-red-500 ml-4 text-base'>
|
||||
{t("music.playlists.delete_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const _styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
262
components/music/PlaylistPickerSheet.tsx
Normal file
262
components/music/PlaylistPickerSheet.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
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";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useAddToPlaylist } from "@/hooks/usePlaylistMutations";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
trackToAdd: BaseItemDto | null;
|
||||
onCreateNew: () => void;
|
||||
}
|
||||
|
||||
export const PlaylistPickerSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
trackToAdd,
|
||||
onCreateNew,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const addToPlaylist = useAddToPlaylist();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const snapPoints = useMemo(() => ["75%"], []);
|
||||
|
||||
// Fetch all playlists
|
||||
const { data: playlists, isLoading } = useQuery({
|
||||
queryKey: ["music-playlists-picker", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
userId: user.Id,
|
||||
includeItemTypes: ["Playlist"],
|
||||
sortBy: ["SortName"],
|
||||
sortOrder: ["Ascending"],
|
||||
recursive: true,
|
||||
mediaTypes: ["Audio"],
|
||||
});
|
||||
|
||||
return response.data.Items || [];
|
||||
},
|
||||
enabled: Boolean(api && user?.Id && open),
|
||||
});
|
||||
|
||||
const filteredPlaylists = useMemo(() => {
|
||||
if (!playlists) return [];
|
||||
if (!search) return playlists;
|
||||
return playlists.filter((playlist) =>
|
||||
playlist.Name?.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}, [playlists, search]);
|
||||
|
||||
const showSearch = (playlists?.length || 0) > 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch("");
|
||||
bottomSheetModalRef.current?.present();
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectPlaylist = useCallback(
|
||||
async (playlist: BaseItemDto) => {
|
||||
if (!trackToAdd?.Id || !playlist.Id) return;
|
||||
|
||||
await addToPlaylist.mutateAsync({
|
||||
playlistId: playlist.Id,
|
||||
trackIds: [trackToAdd.Id],
|
||||
playlistName: playlist.Name || undefined,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
[trackToAdd, addToPlaylist, setOpen],
|
||||
);
|
||||
|
||||
const handleCreateNew = useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
onCreateNew();
|
||||
}, 300);
|
||||
}, [onCreateNew, setOpen]);
|
||||
|
||||
const getPlaylistImageUrl = useCallback(
|
||||
(playlist: BaseItemDto) => {
|
||||
if (!api) return null;
|
||||
return `${api.basePath}/Items/${playlist.Id}/Images/Primary?maxHeight=100&maxWidth=100`;
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom + 16,
|
||||
}}
|
||||
>
|
||||
<Text className='font-bold text-2xl mb-2'>
|
||||
{t("music.track_options.add_to_playlist")}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 mb-4'>{trackToAdd?.Name}</Text>
|
||||
|
||||
{showSearch && (
|
||||
<Input
|
||||
placeholder={t("music.playlists.search_playlists")}
|
||||
className='mb-4 border-neutral-800 border'
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
returnKeyType='done'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create New Playlist Button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleCreateNew}
|
||||
className='flex-row items-center bg-purple-900/30 rounded-xl px-4 py-3.5 mb-4'
|
||||
>
|
||||
<View className='w-12 h-12 rounded-lg bg-purple-600 items-center justify-center mr-3'>
|
||||
<Ionicons name='add' size={28} color='white' />
|
||||
</View>
|
||||
<Text className='text-purple-400 font-semibold text-base'>
|
||||
{t("music.playlists.create_new")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isLoading ? (
|
||||
<View className='py-8 items-center'>
|
||||
<ActivityIndicator color='#9334E9' />
|
||||
</View>
|
||||
) : filteredPlaylists.length === 0 ? (
|
||||
<View className='py-8 items-center'>
|
||||
<Text className='text-neutral-500'>
|
||||
{search ? t("search.no_results") : t("music.no_playlists")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className='rounded-xl overflow-hidden bg-neutral-800'>
|
||||
{filteredPlaylists.map((playlist, index) => (
|
||||
<View key={playlist.Id}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSelectPlaylist(playlist)}
|
||||
className='flex-row items-center px-4 py-3'
|
||||
disabled={addToPlaylist.isPending}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: getPlaylistImageUrl(playlist) || undefined,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text numberOfLines={1} className='text-white text-base'>
|
||||
{playlist.Name}
|
||||
</Text>
|
||||
<Text className='text-neutral-500 text-sm'>
|
||||
{playlist.ChildCount} {t("music.tabs.tracks")}
|
||||
</Text>
|
||||
</View>
|
||||
{addToPlaylist.isPending && (
|
||||
<ActivityIndicator size='small' color='#9334E9' />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{index < filteredPlaylists.length - 1 && (
|
||||
<View style={styles.separator} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
173
components/music/PlaylistSortSheet.tsx
Normal file
173
components/music/PlaylistSortSheet.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} 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";
|
||||
|
||||
export type PlaylistSortOrder = "Ascending" | "Descending";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
sortBy: PlaylistSortOption;
|
||||
sortOrder: PlaylistSortOrder;
|
||||
onSortChange: (
|
||||
sortBy: PlaylistSortOption,
|
||||
sortOrder: PlaylistSortOrder,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: { key: PlaylistSortOption; label: string; icon: string }[] =
|
||||
[
|
||||
{ key: "SortName", label: "music.sort.alphabetical", icon: "text-outline" },
|
||||
{
|
||||
key: "DateCreated",
|
||||
label: "music.sort.date_created",
|
||||
icon: "time-outline",
|
||||
},
|
||||
];
|
||||
|
||||
export const PlaylistSortSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const snapPoints = useMemo(() => ["40%"], []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[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
|
||||
if (option === sortBy) {
|
||||
onSortChange(
|
||||
option,
|
||||
sortOrder === "Ascending" ? "Descending" : "Ascending",
|
||||
);
|
||||
} else {
|
||||
// Default order based on sort type
|
||||
const defaultOrder: PlaylistSortOrder =
|
||||
option === "SortName" ? "Ascending" : "Descending";
|
||||
onSortChange(option, defaultOrder);
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[sortBy, sortOrder, onSortChange, setOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
<Text className='text-white text-lg font-semibold mb-4'>
|
||||
{t("music.sort.title")}
|
||||
</Text>
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||
{SORT_OPTIONS.map((option, index) => {
|
||||
const isSelected = sortBy === option.key;
|
||||
return (
|
||||
<React.Fragment key={option.key}>
|
||||
{index > 0 && <View style={styles.separator} />}
|
||||
<TouchableOpacity
|
||||
onPress={() => handleSortSelect(option.key)}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons
|
||||
name={option.icon as any}
|
||||
size={22}
|
||||
color={isSelected ? "#9334E9" : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
className={`ml-4 text-base flex-1 ${isSelected ? "text-purple-500 font-medium" : "text-white"}`}
|
||||
>
|
||||
{t(option.label)}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<View className='flex-row items-center'>
|
||||
<Ionicons
|
||||
name={
|
||||
sortOrder === "Ascending" ? "arrow-up" : "arrow-down"
|
||||
}
|
||||
size={18}
|
||||
color='#9334E9'
|
||||
/>
|
||||
<Ionicons
|
||||
name='checkmark'
|
||||
size={22}
|
||||
color='#9334E9'
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
458
components/music/TrackOptionsSheet.tsx
Normal file
458
components/music/TrackOptionsSheet.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
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";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
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 { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
deleteTrack,
|
||||
downloadTrack,
|
||||
isCached,
|
||||
isPermanentDownloading,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
track: BaseItemDto | null;
|
||||
onAddToPlaylist: () => void;
|
||||
playlistId?: string;
|
||||
onRemoveFromPlaylist?: () => void;
|
||||
}
|
||||
|
||||
export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
open,
|
||||
setOpen,
|
||||
track,
|
||||
onAddToPlaylist,
|
||||
playlistId,
|
||||
onRemoveFromPlaylist,
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
const { playNext, addToQueue } = useMusicPlayer();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
|
||||
// Counter to trigger re-evaluation of download status when storage changes
|
||||
const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
|
||||
|
||||
// Listen for storage events to update download status
|
||||
useEffect(() => {
|
||||
const handleComplete = (event: { itemId: string }) => {
|
||||
if (event.itemId === track?.Id) {
|
||||
setStorageUpdateCounter((c) => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", handleComplete);
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", handleComplete);
|
||||
};
|
||||
}, [track?.Id]);
|
||||
|
||||
// Force re-evaluation of cache status when track changes
|
||||
useEffect(() => {
|
||||
setStorageUpdateCounter((c) => c + 1);
|
||||
}, [track?.Id]);
|
||||
|
||||
// Use a placeholder item for useFavorite when track is null
|
||||
const { isFavorite, toggleFavorite } = useFavorite(
|
||||
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
|
||||
);
|
||||
|
||||
// Check download status (storageUpdateCounter triggers re-evaluation when download completes)
|
||||
const isAlreadyDownloaded = useMemo(
|
||||
() => isPermanentlyDownloaded(track?.Id),
|
||||
[track?.Id, storageUpdateCounter],
|
||||
);
|
||||
const isOnlyCached = useMemo(
|
||||
() => isCached(track?.Id),
|
||||
[track?.Id, storageUpdateCounter],
|
||||
);
|
||||
const isCurrentlyDownloading = useMemo(
|
||||
() => isPermanentDownloading(track?.Id),
|
||||
[track?.Id, storageUpdateCounter],
|
||||
);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!track) return null;
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
if (albumId) {
|
||||
return `${api?.basePath}/Items/${albumId}/Images/Primary?maxHeight=200&maxWidth=200`;
|
||||
}
|
||||
return getPrimaryImageUrl({ api, item: track });
|
||||
}, [api, track]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) bottomSheetModalRef.current?.present();
|
||||
else bottomSheetModalRef.current?.dismiss();
|
||||
}, [open]);
|
||||
|
||||
const handleSheetChanges = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[setOpen],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: BottomSheetBackdropProps) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePlayNext = useCallback(() => {
|
||||
if (track) {
|
||||
playNext(track);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, playNext, setOpen]);
|
||||
|
||||
const handleAddToQueue = useCallback(() => {
|
||||
if (track) {
|
||||
addToQueue(track);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, addToQueue, setOpen]);
|
||||
|
||||
const handleAddToPlaylist = useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
onAddToPlaylist();
|
||||
}, 300);
|
||||
}, [onAddToPlaylist, setOpen]);
|
||||
|
||||
const handleRemoveFromPlaylist = useCallback(() => {
|
||||
if (onRemoveFromPlaylist) {
|
||||
onRemoveFromPlaylist();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [onRemoveFromPlaylist, setOpen]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
|
||||
|
||||
setIsDownloadingTrack(true);
|
||||
try {
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, {
|
||||
permanent: true,
|
||||
container: result.mediaSource?.Container || undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloadingTrack(false);
|
||||
setOpen(false);
|
||||
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!track?.Id) return;
|
||||
await deleteTrack(track.Id);
|
||||
setStorageUpdateCounter((c) => c + 1);
|
||||
setOpen(false);
|
||||
}, [track?.Id, setOpen]);
|
||||
|
||||
const handleGoToArtist = useCallback(() => {
|
||||
const artistId = track?.ArtistItems?.[0]?.Id;
|
||||
if (artistId) {
|
||||
setOpen(false);
|
||||
router.push(`/music/artist/${artistId}`);
|
||||
}
|
||||
}, [track?.ArtistItems, router, setOpen]);
|
||||
|
||||
const handleGoToAlbum = useCallback(() => {
|
||||
const albumId = track?.AlbumId || track?.ParentId;
|
||||
if (albumId) {
|
||||
setOpen(false);
|
||||
router.push(`/music/album/${albumId}`);
|
||||
}
|
||||
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
||||
|
||||
const handleToggleFavorite = useCallback(() => {
|
||||
if (track) {
|
||||
toggleFavorite();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, toggleFavorite, setOpen]);
|
||||
|
||||
// Check if navigation options are available
|
||||
const hasArtist = !!track?.ArtistItems?.[0]?.Id;
|
||||
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChanges}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: "white",
|
||||
}}
|
||||
backgroundStyle={{
|
||||
backgroundColor: "#171717",
|
||||
}}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: Math.max(16, insets.left),
|
||||
paddingRight: Math.max(16, insets.right),
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
>
|
||||
{/* Track Info Header */}
|
||||
<View className='flex-row items-center mb-6 px-2'>
|
||||
<View
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: imageUrl }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
contentFit='cover'
|
||||
cachePolicy='memory-disk'
|
||||
/>
|
||||
) : (
|
||||
<View className='flex-1 items-center justify-center bg-neutral-800'>
|
||||
<Ionicons name='musical-note' size={24} color='#737373' />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='flex-1'>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className='text-white font-semibold text-base'
|
||||
>
|
||||
{track.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-neutral-400 text-sm mt-0.5'>
|
||||
{track.Artists?.join(", ") || track.AlbumArtist}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Playback Options */}
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayNext}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='play-forward' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.play_next")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddToQueue}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='list' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.add_to_queue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Library Options */}
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleFavorite}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons
|
||||
name={isFavorite ? "heart" : "heart-outline"}
|
||||
size={22}
|
||||
color={isFavorite ? "#ec4899" : "white"}
|
||||
/>
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleAddToPlaylist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='albums-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.add_to_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{playlistId && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<TouchableOpacity
|
||||
onPress={handleRemoveFromPlaylist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||
<Text className='text-red-500 ml-4 text-base'>
|
||||
{t("music.track_options.remove_from_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDownload}
|
||||
disabled={
|
||||
isAlreadyDownloaded ||
|
||||
isCurrentlyDownloading ||
|
||||
isDownloadingTrack
|
||||
}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
{isCurrentlyDownloading || isDownloadingTrack ? (
|
||||
<ActivityIndicator size={22} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
isAlreadyDownloaded ? "checkmark-circle" : "download-outline"
|
||||
}
|
||||
size={22}
|
||||
color={isAlreadyDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
className={`ml-4 text-base ${isAlreadyDownloaded ? "text-green-500" : "text-white"}`}
|
||||
>
|
||||
{isCurrentlyDownloading || isDownloadingTrack
|
||||
? t("music.track_options.downloading")
|
||||
: isAlreadyDownloaded
|
||||
? t("music.track_options.downloaded")
|
||||
: t("music.track_options.download")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isOnlyCached && !isAlreadyDownloaded && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<View className='flex-row items-center px-4 py-3.5'>
|
||||
<Ionicons name='cloud-done-outline' size={22} color='#737373' />
|
||||
<Text className='text-neutral-500 ml-4 text-base'>
|
||||
{t("music.track_options.cached")}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(isAlreadyDownloaded || isOnlyCached) && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<TouchableOpacity
|
||||
onPress={handleDelete}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='trash-outline' size={22} color='#ef4444' />
|
||||
<Text className='text-red-500 ml-4 text-base'>
|
||||
{isAlreadyDownloaded
|
||||
? t("music.track_options.delete_download")
|
||||
: t("music.track_options.delete_cache")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Navigation Options */}
|
||||
{(hasArtist || hasAlbum) && (
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||
{hasArtist && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToArtist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='person-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.go_to_artist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{hasAlbum && <View style={styles.separator} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasAlbum && (
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToAlbum}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='disc-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.go_to_album")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: "#404040",
|
||||
},
|
||||
});
|
||||
6
components/music/index.ts
Normal file
6
components/music/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./MiniPlayerBar";
|
||||
export * from "./MusicAlbumCard";
|
||||
export * from "./MusicArtistCard";
|
||||
export * from "./MusicPlaybackEngine";
|
||||
export * from "./MusicPlaylistCard";
|
||||
export * from "./MusicTrackItem";
|
||||
@@ -1,12 +0,0 @@
|
||||
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
|
||||
|
||||
import type { IconProps } from "@expo/vector-icons/build/createIconSet";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function TabBarIcon({
|
||||
style,
|
||||
...rest
|
||||
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
|
||||
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { WatchedIndicator } from "@/components/WatchedIndicator";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type MoviePosterProps = {
|
||||
item: BaseItemDto;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
export const EpisodePoster: React.FC<MoviePosterProps> = ({
|
||||
item,
|
||||
showProgress = false,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(() => {
|
||||
if (item.Type === "Episode") {
|
||||
return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const [progress, _setProgress] = useState(
|
||||
item.UserData?.PlayedPercentage || 0,
|
||||
);
|
||||
|
||||
const blurhash = useMemo(() => {
|
||||
const key = item.ImageTags?.Primary as string;
|
||||
return item.ImageBlurHashes?.Primary?.[key];
|
||||
}, [item]);
|
||||
|
||||
return (
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900'>
|
||||
<Image
|
||||
placeholder={{
|
||||
blurhash,
|
||||
}}
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
source={
|
||||
url
|
||||
? {
|
||||
uri: url,
|
||||
}
|
||||
: null
|
||||
}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
<WatchedIndicator item={item} />
|
||||
{showProgress && progress > 0 && (
|
||||
<View className='h-1 bg-red-600 w-full' />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
type PosterProps = {
|
||||
id?: string;
|
||||
showProgress?: boolean;
|
||||
};
|
||||
|
||||
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const url = useMemo(
|
||||
() => `${api?.basePath}/Items/${id}/Images/Primary`,
|
||||
[id],
|
||||
);
|
||||
|
||||
if (!url || !id)
|
||||
return (
|
||||
<View
|
||||
className='border border-neutral-900'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className='rounded-lg overflow-hidden border border-neutral-900'>
|
||||
<Image
|
||||
key={id}
|
||||
id={id}
|
||||
source={{
|
||||
uri: url,
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
style={{
|
||||
aspectRatio: "10/15",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentPoster;
|
||||
@@ -7,15 +7,15 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
|
||||
import { TouchableSeerrRouter } from "@/components/common/SeerrItemRouter";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Tag, Tags } from "@/components/GenreTags";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||
import SeerrMediaIcon from "@/components/seerr/SeerrMediaIcon";
|
||||
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { useSeerrCanRequest } from "@/utils/_seerr/useSeerrCanRequest";
|
||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
|
||||
@@ -34,13 +34,13 @@ interface Props extends ViewProps {
|
||||
mediaRequest?: MediaRequest;
|
||||
}
|
||||
|
||||
const JellyseerrPoster: React.FC<Props> = ({
|
||||
const SeerrPoster: React.FC<Props> = ({
|
||||
item,
|
||||
horizontal,
|
||||
showDownloadInfo,
|
||||
mediaRequest,
|
||||
}) => {
|
||||
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
|
||||
const { seerrApi, getTitle, getYear, getMediaType } = useSeerr();
|
||||
const loadingOpacity = useSharedValue(1);
|
||||
const imageOpacity = useSharedValue(0);
|
||||
const { t } = useTranslation();
|
||||
@@ -56,16 +56,13 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
|
||||
const backdropSrc = useMemo(
|
||||
() =>
|
||||
jellyseerrApi?.imageProxy(
|
||||
item?.backdropPath,
|
||||
"w1920_and_h800_multi_faces",
|
||||
),
|
||||
[item, jellyseerrApi, horizontal],
|
||||
seerrApi?.imageProxy(item?.backdropPath, "w1920_and_h800_multi_faces"),
|
||||
[item, seerrApi, horizontal],
|
||||
);
|
||||
|
||||
const posterSrc = useMemo(
|
||||
() => jellyseerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
|
||||
[item, jellyseerrApi, horizontal],
|
||||
() => seerrApi?.imageProxy(item?.posterPath, "w300_and_h450_face"),
|
||||
[item, seerrApi, horizontal],
|
||||
);
|
||||
|
||||
const title = useMemo(() => getTitle(item), [item]);
|
||||
@@ -75,7 +72,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
const size = useMemo(() => (horizontal ? "h-28" : "w-28"), [horizontal]);
|
||||
const ratio = useMemo(() => (horizontal ? "15/10" : "10/15"), [horizontal]);
|
||||
|
||||
const [canRequest] = useJellyseerrCanRequest(item);
|
||||
const [canRequest] = useSeerrCanRequest(item);
|
||||
|
||||
const is4k = useMemo(() => mediaRequest?.is4k === true, [mediaRequest]);
|
||||
|
||||
@@ -109,7 +106,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
second,
|
||||
third,
|
||||
fourth,
|
||||
t("home.settings.plugins.jellyseerr.plus_n_more", { n: rest.length }),
|
||||
t("home.settings.plugins.seerr.plus_n_more", { n: rest.length }),
|
||||
];
|
||||
}
|
||||
return seasons;
|
||||
@@ -121,7 +118,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
}, [mediaRequest, is4k]);
|
||||
|
||||
return (
|
||||
<TouchableJellyseerrRouter
|
||||
<TouchableSeerrRouter
|
||||
result={item}
|
||||
mediaTitle={title}
|
||||
releaseYear={releaseYear}
|
||||
@@ -184,12 +181,12 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<JellyseerrStatusIcon
|
||||
<SeerrStatusIcon
|
||||
className='absolute bottom-1 right-1'
|
||||
showRequestIcon={canRequest}
|
||||
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
|
||||
/>
|
||||
<JellyseerrMediaIcon
|
||||
<SeerrMediaIcon
|
||||
className='absolute top-1 left-1'
|
||||
mediaType={mediaType}
|
||||
/>
|
||||
@@ -201,8 +198,8 @@ const JellyseerrPoster: React.FC<Props> = ({
|
||||
{releaseYear || ""}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableJellyseerrRouter>
|
||||
</TouchableSeerrRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyseerrPoster;
|
||||
export default SeerrPoster;
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Button, ContextMenu, Host, Picker } from "@expo/ui/swift-ui";
|
||||
import { Platform, View } from "react-native";
|
||||
import { FilterButton } from "@/components/filters/FilterButton";
|
||||
import { JellyseerrSearchSort } from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||
import { SeerrSearchSort } from "@/components/seerr/SeerrIndexPage";
|
||||
|
||||
interface DiscoverFiltersProps {
|
||||
searchFilterId: string;
|
||||
orderFilterId: string;
|
||||
jellyseerrOrderBy: JellyseerrSearchSort;
|
||||
setJellyseerrOrderBy: (value: JellyseerrSearchSort) => void;
|
||||
jellyseerrSortOrder: "asc" | "desc";
|
||||
setJellyseerrSortOrder: (value: "asc" | "desc") => void;
|
||||
seerrOrderBy: SeerrSearchSort;
|
||||
setSeerrOrderBy: (value: SeerrSearchSort) => void;
|
||||
seerrSortOrder: "asc" | "desc";
|
||||
setSeerrSortOrder: (value: "asc" | "desc") => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const sortOptions = Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
const sortOptions = Object.keys(SeerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
);
|
||||
|
||||
@@ -22,10 +22,10 @@ const orderOptions = ["asc", "desc"] as const;
|
||||
export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
searchFilterId,
|
||||
orderFilterId,
|
||||
jellyseerrOrderBy,
|
||||
setJellyseerrOrderBy,
|
||||
jellyseerrSortOrder,
|
||||
setJellyseerrSortOrder,
|
||||
seerrOrderBy,
|
||||
setSeerrOrderBy,
|
||||
seerrSortOrder,
|
||||
setSeerrSortOrder,
|
||||
t,
|
||||
}) => {
|
||||
if (Platform.OS === "ios") {
|
||||
@@ -52,16 +52,16 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
<Picker
|
||||
label={t("library.filters.sort_by")}
|
||||
options={sortOptions.map((item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`),
|
||||
t(`home.settings.plugins.seerr.order_by.${item}`),
|
||||
)}
|
||||
variant='menu'
|
||||
selectedIndex={sortOptions.indexOf(
|
||||
jellyseerrOrderBy as unknown as string,
|
||||
seerrOrderBy as unknown as string,
|
||||
)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrOrderBy(
|
||||
sortOptions[index] as unknown as JellyseerrSearchSort,
|
||||
setSeerrOrderBy(
|
||||
sortOptions[index] as unknown as SeerrSearchSort,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -69,10 +69,10 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
label={t("library.filters.sort_order")}
|
||||
options={orderOptions.map((item) => t(`library.filters.${item}`))}
|
||||
variant='menu'
|
||||
selectedIndex={orderOptions.indexOf(jellyseerrSortOrder)}
|
||||
selectedIndex={orderOptions.indexOf(seerrSortOrder)}
|
||||
onOptionSelected={(event: any) => {
|
||||
const index = event.nativeEvent.index;
|
||||
setJellyseerrSortOrder(orderOptions[index]);
|
||||
setSeerrSortOrder(orderOptions[index]);
|
||||
}}
|
||||
/>
|
||||
</ContextMenu.Items>
|
||||
@@ -86,17 +86,15 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||
<FilterButton
|
||||
id={searchFilterId}
|
||||
queryKey='jellyseerr_search'
|
||||
queryKey='seerr_search'
|
||||
queryFn={async () =>
|
||||
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||
Number.isNaN(Number(v)),
|
||||
)
|
||||
Object.keys(SeerrSearchSort).filter((v) => Number.isNaN(Number(v)))
|
||||
}
|
||||
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||
values={[jellyseerrOrderBy]}
|
||||
set={(value) => setSeerrOrderBy(value[0])}
|
||||
values={[seerrOrderBy]}
|
||||
title={t("library.filters.sort_by")}
|
||||
renderItemLabel={(item) =>
|
||||
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||
t(`home.settings.plugins.seerr.order_by.${item}`)
|
||||
}
|
||||
disableSearch={true}
|
||||
/>
|
||||
@@ -104,8 +102,8 @@ export const DiscoverFilters: React.FC<DiscoverFiltersProps> = ({
|
||||
id={orderFilterId}
|
||||
queryKey='jellysearr_search'
|
||||
queryFn={async () => ["asc", "desc"]}
|
||||
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||
values={[jellyseerrSortOrder]}
|
||||
set={(value) => setSeerrSortOrder(value[0])}
|
||||
values={[seerrSortOrder]}
|
||||
title={t("library.filters.sort_order")}
|
||||
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||
disableSearch={true}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import PersonPoster from "@/components/jellyseerr/PersonPoster";
|
||||
import PersonPoster from "@/components/seerr/PersonPoster";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
|
||||
@@ -15,9 +15,7 @@ const CastSlide: React.FC<
|
||||
details?.credits?.cast &&
|
||||
details?.credits?.cast?.length > 0 && (
|
||||
<View {...props}>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>
|
||||
{t("jellyseerr.cast")}
|
||||
</Text>
|
||||
<Text className='text-lg font-bold mb-2 px-4'>{t("seerr.cast")}</Text>
|
||||
<FlashList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import CountryFlag from "react-native-country-flag";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||
import type { TmdbRelease } from "@/utils/jellyseerr/server/api/themoviedb/interfaces";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
@@ -50,8 +50,7 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
|
||||
const DetailFacts: React.FC<
|
||||
{ details?: MovieDetails | TvDetails } & ViewProps
|
||||
> = ({ details, className, ...props }) => {
|
||||
const { jellyseerrRegion: region, jellyseerrLocale: locale } =
|
||||
useJellyseerr();
|
||||
const { seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const releases = useMemo(
|
||||
@@ -132,21 +131,21 @@ const DetailFacts: React.FC<
|
||||
return (
|
||||
details && (
|
||||
<View className='p-4'>
|
||||
<Text className='text-lg font-bold'>{t("jellyseerr.details")}</Text>
|
||||
<Text className='text-lg font-bold'>{t("seerr.details")}</Text>
|
||||
<View
|
||||
className={`${className} flex flex-col justify-center divide-y-2 divide-neutral-800`}
|
||||
{...props}
|
||||
>
|
||||
<Fact title={t("jellyseerr.status")} fact={details?.status} />
|
||||
<Fact title={t("seerr.status")} fact={details?.status} />
|
||||
<Fact
|
||||
title={t("jellyseerr.original_title")}
|
||||
title={t("seerr.original_title")}
|
||||
fact={(details as TvDetails)?.originalName}
|
||||
/>
|
||||
{details.keywords.some(
|
||||
(keyword) => keyword.id === ANIME_KEYWORD_ID,
|
||||
) && <Fact title={t("jellyseerr.series_type")} fact='Anime' />}
|
||||
) && <Fact title={t("seerr.series_type")} fact='Anime' />}
|
||||
<Facts
|
||||
title={t("jellyseerr.release_dates")}
|
||||
title={t("seerr.release_dates")}
|
||||
facts={filteredReleases?.map?.((r: Release, idx) => (
|
||||
<View key={idx} className='flex flex-row space-x-2 items-center'>
|
||||
{r.type === 3 ? (
|
||||
@@ -172,16 +171,13 @@ const DetailFacts: React.FC<
|
||||
</View>
|
||||
))}
|
||||
/>
|
||||
<Fact title={t("jellyseerr.first_air_date")} fact={firstAirDate} />
|
||||
<Fact title={t("jellyseerr.next_air_date")} fact={nextAirDate} />
|
||||
<Fact title={t("jellyseerr.revenue")} fact={revenue} />
|
||||
<Fact title={t("jellyseerr.budget")} fact={budget} />
|
||||
<Fact
|
||||
title={t("jellyseerr.original_language")}
|
||||
fact={spokenLanguage}
|
||||
/>
|
||||
<Fact title={t("seerr.first_air_date")} fact={firstAirDate} />
|
||||
<Fact title={t("seerr.next_air_date")} fact={nextAirDate} />
|
||||
<Fact title={t("seerr.revenue")} fact={revenue} />
|
||||
<Fact title={t("seerr.budget")} fact={budget} />
|
||||
<Fact title={t("seerr.original_language")} fact={spokenLanguage} />
|
||||
<Facts
|
||||
title={t("jellyseerr.production_country")}
|
||||
title={t("seerr.production_country")}
|
||||
facts={details?.productionCountries?.map((n, idx) => (
|
||||
<View key={idx} className='flex flex-row items-center space-x-2'>
|
||||
<CountryFlag isoCode={n.iso_3166_1} size={10} />
|
||||
@@ -190,17 +186,17 @@ const DetailFacts: React.FC<
|
||||
))}
|
||||
/>
|
||||
<Facts
|
||||
title={t("jellyseerr.studios")}
|
||||
title={t("seerr.studios")}
|
||||
facts={uniqBy(details?.productionCompanies, "name")?.map(
|
||||
(n) => n.name,
|
||||
)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("jellyseerr.network")}
|
||||
title={t("seerr.network")}
|
||||
facts={networks?.map((n) => n.name)}
|
||||
/>
|
||||
<Facts
|
||||
title={t("jellyseerr.currently_streaming_on")}
|
||||
title={t("seerr.currently_streaming_on")}
|
||||
facts={streamingProviders?.map((s) => s.name)}
|
||||
/>
|
||||
</View>
|
||||
21
components/seerr/GridSkeleton.tsx
Normal file
21
components/seerr/GridSkeleton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
}
|
||||
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
|
||||
export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
className='flex flex-col mr-2 h-auto'
|
||||
style={{ width: "30.5%" }}
|
||||
>
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
|
||||
<View className='mt-2 flex flex-col w-full'>
|
||||
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
||||
<View className='h-3 bg-neutral-800 rounded w-1/2' />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { Animated, View, type ViewProps } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
import { GridSkeleton } from "./GridSkeleton";
|
||||
|
||||
const ANIMATION_ENTER = 250;
|
||||
const ANIMATION_EXIT = 250;
|
||||
@@ -28,6 +29,7 @@ interface Props<T> {
|
||||
renderItem: (item: T, index: number) => Render;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const ParallaxSlideShow = <T,>({
|
||||
@@ -40,6 +42,7 @@ const ParallaxSlideShow = <T,>({
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
onEndReached,
|
||||
isLoading = false,
|
||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
@@ -124,27 +127,40 @@ const ParallaxSlideShow = <T,>({
|
||||
</View>
|
||||
{MainContent?.()}
|
||||
<View>
|
||||
<FlashList
|
||||
data={data}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
No results
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={
|
||||
{isLoading ? (
|
||||
<View>
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
<View className='px-4'>
|
||||
<View className='flex flex-row flex-wrap'>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<GridSkeleton key={i} index={i} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<FlashList
|
||||
data={data}
|
||||
ListEmptyComponent={
|
||||
<View className='flex flex-col items-center justify-center h-full'>
|
||||
<Text className='font-bold text-xl text-neutral-500'>
|
||||
No results
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
contentInsetAdjustmentBehavior='automatic'
|
||||
ListHeaderComponent={
|
||||
<Text className='text-lg font-bold my-2'>{listHeader}</Text>
|
||||
}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
//@ts-expect-error
|
||||
renderItem={({ item, index }) => renderItem(item, index)}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={3}
|
||||
ItemSeparatorComponent={() => <View className='h-2 w-2' />}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ParallaxScrollView>
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -19,7 +20,7 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
subName,
|
||||
...props
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
@@ -27,14 +28,12 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push(`/(auth)/(tabs)/${from}/jellyseerr/person/${id}`)
|
||||
}
|
||||
onPress={() => router.push(`/(auth)/(tabs)/${from}/seerr/person/${id}`)}
|
||||
>
|
||||
<View className='flex flex-col w-28' {...props}>
|
||||
<Poster
|
||||
id={id}
|
||||
url={jellyseerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
||||
url={seerrApi?.imageProxy(posterPath, "w600_and_h900_bestv2")}
|
||||
/>
|
||||
<Text className='mt-2'>{name}</Text>
|
||||
{subName && <Text className='text-xs opacity-50'>{subName}</Text>}
|
||||
@@ -12,7 +12,7 @@ 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 { useSeerr } from "@/hooks/useSeerr";
|
||||
import type {
|
||||
QualityProfile,
|
||||
RootFolder,
|
||||
@@ -41,11 +41,11 @@ const RequestModal = forwardRef<
|
||||
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||
ref,
|
||||
) => {
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||
mediaId: Number(id),
|
||||
mediaType: type,
|
||||
userId: jellyseerrUser?.id,
|
||||
userId: seerrUser?.id,
|
||||
});
|
||||
|
||||
const [qualityProfileOpen, setQualityProfileOpen] = useState(false);
|
||||
@@ -65,18 +65,17 @@ const RequestModal = forwardRef<
|
||||
}, [onDismiss]);
|
||||
|
||||
const { data: serviceSettings } = useQuery({
|
||||
queryKey: ["jellyseerr", "request", type, "service"],
|
||||
queryKey: ["seerr", "request", type, "service"],
|
||||
queryFn: async () =>
|
||||
jellyseerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
||||
seerrApi?.service(type === "movie" ? "radarr" : "sonarr"),
|
||||
enabled: !!seerrApi && !!seerrUser,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ["jellyseerr", "users"],
|
||||
queryFn: async () =>
|
||||
jellyseerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser,
|
||||
queryKey: ["seerr", "users"],
|
||||
queryFn: async () => seerrApi?.user({ take: 1000, sort: "displayname" }),
|
||||
enabled: !!seerrApi && !!seerrUser,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
@@ -87,7 +86,7 @@ const RequestModal = forwardRef<
|
||||
|
||||
const { data: defaultServiceDetails } = useQuery({
|
||||
queryKey: [
|
||||
"jellyseerr",
|
||||
"seerr",
|
||||
"request",
|
||||
type,
|
||||
"service",
|
||||
@@ -99,12 +98,12 @@ const RequestModal = forwardRef<
|
||||
...prev,
|
||||
serverId: defaultService?.id,
|
||||
}));
|
||||
return jellyseerrApi?.serviceDetails(
|
||||
return seerrApi?.serviceDetails(
|
||||
type === "movie" ? "radarr" : "sonarr",
|
||||
defaultService!.id,
|
||||
);
|
||||
},
|
||||
enabled: !!jellyseerrApi && !!jellyseerrUser && !!defaultService,
|
||||
enabled: !!seerrApi && !!seerrUser && !!defaultService,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
|
||||
@@ -148,9 +147,9 @@ const RequestModal = forwardRef<
|
||||
return undefined;
|
||||
}
|
||||
if (requestBody.seasons.length > 1) {
|
||||
return t("jellyseerr.season_all");
|
||||
return t("seerr.season_all");
|
||||
}
|
||||
return t("jellyseerr.season_number", {
|
||||
return t("seerr.season_number", {
|
||||
season_number: requestBody.seasons[0],
|
||||
});
|
||||
}, [requestBody?.seasons]);
|
||||
@@ -245,8 +244,7 @@ const RequestModal = forwardRef<
|
||||
type: "radio" as const,
|
||||
label: user.displayName,
|
||||
value: user.id.toString(),
|
||||
selected:
|
||||
(requestOverrides.userId || jellyseerrUser?.id) === user.id,
|
||||
selected: (requestOverrides.userId || seerrUser?.id) === user.id,
|
||||
onPress: () =>
|
||||
setRequestOverrides((prev) => ({
|
||||
...prev,
|
||||
@@ -255,7 +253,7 @@ const RequestModal = forwardRef<
|
||||
})) || [],
|
||||
},
|
||||
],
|
||||
[users, jellyseerrUser, requestOverrides.userId],
|
||||
[users, seerrUser, requestOverrides.userId],
|
||||
);
|
||||
|
||||
const request = useCallback(() => {
|
||||
@@ -268,7 +266,7 @@ const RequestModal = forwardRef<
|
||||
...requestOverrides,
|
||||
};
|
||||
|
||||
writeDebugLog("Sending Jellyseerr advanced request", body);
|
||||
writeDebugLog("Sending Seerr advanced request", body);
|
||||
|
||||
requestMedia(
|
||||
seasonTitle ? `${title}, ${seasonTitle}` : title,
|
||||
@@ -308,7 +306,7 @@ const RequestModal = forwardRef<
|
||||
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||
<View>
|
||||
<Text className='font-bold text-2xl text-neutral-100'>
|
||||
{t("jellyseerr.advanced")}
|
||||
{t("seerr.advanced")}
|
||||
</Text>
|
||||
{seasonTitle && (
|
||||
<Text className='text-neutral-300'>{seasonTitle}</Text>
|
||||
@@ -319,7 +317,7 @@ const RequestModal = forwardRef<
|
||||
<>
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.quality_profile")}
|
||||
{t("seerr.quality_profile")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={qualityProfileOptions}
|
||||
@@ -335,7 +333,7 @@ const RequestModal = forwardRef<
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
title={t("seerr.quality_profile")}
|
||||
open={qualityProfileOpen}
|
||||
onOpenChange={setQualityProfileOpen}
|
||||
/>
|
||||
@@ -343,7 +341,7 @@ const RequestModal = forwardRef<
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.root_folder")}
|
||||
{t("seerr.root_folder")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={rootFolderOptions}
|
||||
@@ -368,42 +366,45 @@ const RequestModal = forwardRef<
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
title={t("seerr.root_folder")}
|
||||
open={rootFolderOpen}
|
||||
onOpenChange={setRootFolderOpen}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
{defaultServiceDetails?.tags &&
|
||||
defaultServiceDetails.tags.length > 0 && (
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("seerr.tags")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={tagsOptions}
|
||||
trigger={
|
||||
<View className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||
<Text numberOfLines={1}>
|
||||
{requestOverrides.tags
|
||||
? defaultServiceDetails.tags
|
||||
.filter((t) =>
|
||||
requestOverrides.tags!.includes(t.id),
|
||||
)
|
||||
.map((t) => t.label)
|
||||
.join(", ") ||
|
||||
defaultTags.map((t) => t.label).join(", ")
|
||||
: defaultTags.map((t) => t.label).join(", ")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("seerr.tags")}
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='flex flex-col'>
|
||||
<Text className='opacity-50 mb-1 text-xs'>
|
||||
{t("jellyseerr.request_as")}
|
||||
{t("seerr.request_as")}
|
||||
</Text>
|
||||
<PlatformDropdown
|
||||
groups={usersOptions}
|
||||
@@ -413,12 +414,12 @@ const RequestModal = forwardRef<
|
||||
{users.find(
|
||||
(u) =>
|
||||
u.id ===
|
||||
(requestOverrides.userId || jellyseerrUser?.id),
|
||||
)?.displayName || jellyseerrUser!.displayName}
|
||||
(requestOverrides.userId || seerrUser?.id),
|
||||
)?.displayName || seerrUser!.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
title={t("jellyseerr.request_as")}
|
||||
title={t("seerr.request_as")}
|
||||
open={usersOpen}
|
||||
onOpenChange={setUsersOpen}
|
||||
/>
|
||||
@@ -427,7 +428,7 @@ const RequestModal = forwardRef<
|
||||
)}
|
||||
</View>
|
||||
<Button className='mt-auto' onPress={request} color='purple'>
|
||||
{t("jellyseerr.request_button")}
|
||||
{t("seerr.request_button")}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Discover from "@/components/jellyseerr/discover/Discover";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import Discover from "@/components/seerr/discover/Discover";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type {
|
||||
MovieResult,
|
||||
@@ -18,57 +18,57 @@ import type {
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
import { useReactNavigationQuery } from "@/utils/useReactNavigationQuery";
|
||||
import { Text } from "../common/Text";
|
||||
import JellyseerrPoster from "../posters/JellyseerrPoster";
|
||||
import SeerrPoster from "../posters/SeerrPoster";
|
||||
import { LoadingSkeleton } from "../search/LoadingSkeleton";
|
||||
import { SearchItemWrapper } from "../search/SearchItemWrapper";
|
||||
import PersonPoster from "./PersonPoster";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
searchQuery: string;
|
||||
sortType?: JellyseerrSearchSort;
|
||||
sortType?: SeerrSearchSort;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export enum JellyseerrSearchSort {
|
||||
export enum SeerrSearchSort {
|
||||
DEFAULT = 0,
|
||||
VOTE_COUNT_AND_AVERAGE = 1,
|
||||
POPULARITY = 2,
|
||||
}
|
||||
|
||||
export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
export const SeerrIndexPage: React.FC<Props> = ({
|
||||
searchQuery,
|
||||
sortType,
|
||||
order,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
const opacity = useSharedValue(1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: jellyseerrDiscoverSettings,
|
||||
data: seerrDiscoverSettings,
|
||||
isFetching: f1,
|
||||
isLoading: l1,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "jellyseerr", "discoverSettings", searchQuery],
|
||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
||||
enabled: !!jellyseerrApi && searchQuery.length === 0,
|
||||
queryKey: ["search", "seerr", "discoverSettings", searchQuery],
|
||||
queryFn: async () => seerrApi?.discoverSettings(),
|
||||
enabled: !!seerrApi && searchQuery.length === 0,
|
||||
});
|
||||
|
||||
const {
|
||||
data: jellyseerrResults,
|
||||
data: seerrResults,
|
||||
isFetching: f2,
|
||||
isLoading: l2,
|
||||
} = useReactNavigationQuery({
|
||||
queryKey: ["search", "jellyseerr", "results", searchQuery],
|
||||
queryKey: ["search", "seerr", "results", searchQuery],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
query: new URLSearchParams(searchQuery || "").toString(),
|
||||
};
|
||||
return await Promise.all([
|
||||
jellyseerrApi?.search({ ...params, page: 1 }),
|
||||
jellyseerrApi?.search({ ...params, page: 2 }),
|
||||
jellyseerrApi?.search({ ...params, page: 3 }),
|
||||
jellyseerrApi?.search({ ...params, page: 4 }),
|
||||
seerrApi?.search({ ...params, page: 1 }),
|
||||
seerrApi?.search({ ...params, page: 2 }),
|
||||
seerrApi?.search({ ...params, page: 3 }),
|
||||
seerrApi?.search({ ...params, page: 4 }),
|
||||
]).then((all) =>
|
||||
uniqBy(
|
||||
all.flatMap((v) => v?.results || []),
|
||||
@@ -76,7 +76,7 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
),
|
||||
);
|
||||
},
|
||||
enabled: !!jellyseerrApi && searchQuery.length > 0,
|
||||
enabled: !!seerrApi && searchQuery.length > 0,
|
||||
});
|
||||
|
||||
useAnimatedReaction(
|
||||
@@ -92,20 +92,20 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
|
||||
const sortingType = useMemo(() => {
|
||||
if (!sortType) return;
|
||||
switch (Number(JellyseerrSearchSort[sortType])) {
|
||||
case JellyseerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
||||
switch (Number(SeerrSearchSort[sortType])) {
|
||||
case SeerrSearchSort.VOTE_COUNT_AND_AVERAGE:
|
||||
return ["voteCount", "voteAverage"];
|
||||
case JellyseerrSearchSort.POPULARITY:
|
||||
case SeerrSearchSort.POPULARITY:
|
||||
return ["voteCount", "popularity"];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [sortType, order]);
|
||||
|
||||
const jellyseerrMovieResults = useMemo(
|
||||
const seerrMovieResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
jellyseerrResults?.filter(
|
||||
seerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.MOVIE,
|
||||
) as MovieResult[],
|
||||
sortingType || [
|
||||
@@ -113,41 +113,37 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[jellyseerrResults, sortingType, order],
|
||||
[seerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
const jellyseerrTvResults = useMemo(
|
||||
const seerrTvResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === MediaType.TV,
|
||||
) as TvResult[],
|
||||
seerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
|
||||
sortingType || [
|
||||
(t) => t.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[jellyseerrResults, sortingType, order],
|
||||
[seerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
const jellyseerrPersonResults = useMemo(
|
||||
const seerrPersonResults = useMemo(
|
||||
() =>
|
||||
orderBy(
|
||||
jellyseerrResults?.filter(
|
||||
(r) => r.mediaType === "person",
|
||||
) as PersonResult[],
|
||||
seerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
|
||||
sortingType || [
|
||||
(p) => p.name.toLowerCase() === searchQuery.toLowerCase(),
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[jellyseerrResults, sortingType, order],
|
||||
[seerrResults, sortingType, order],
|
||||
);
|
||||
|
||||
if (!searchQuery.length)
|
||||
return (
|
||||
<View className='flex flex-col'>
|
||||
<Discover sliders={jellyseerrDiscoverSettings} />
|
||||
<Discover sliders={seerrDiscoverSettings} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -155,9 +151,9 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
<View>
|
||||
<LoadingSkeleton isLoading={f1 || f2 || l1 || l2} />
|
||||
|
||||
{!jellyseerrMovieResults?.length &&
|
||||
!jellyseerrTvResults?.length &&
|
||||
!jellyseerrPersonResults?.length &&
|
||||
{!seerrMovieResults?.length &&
|
||||
!seerrTvResults?.length &&
|
||||
!seerrPersonResults?.length &&
|
||||
!f1 &&
|
||||
!f2 &&
|
||||
!l1 &&
|
||||
@@ -175,21 +171,21 @@ export const JellyserrIndexPage: React.FC<Props> = ({
|
||||
<View className={f1 || f2 || l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||
<SearchItemWrapper
|
||||
header={t("search.request_movies")}
|
||||
items={jellyseerrMovieResults}
|
||||
items={seerrMovieResults}
|
||||
renderItem={(item: MovieResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
<SeerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header={t("search.request_series")}
|
||||
items={jellyseerrTvResults}
|
||||
items={seerrTvResults}
|
||||
renderItem={(item: TvResult) => (
|
||||
<JellyseerrPoster item={item} key={item.id} />
|
||||
<SeerrPoster item={item} key={item.id} />
|
||||
)}
|
||||
/>
|
||||
<SearchItemWrapper
|
||||
header={t("search.actors")}
|
||||
items={jellyseerrPersonResults}
|
||||
items={seerrPersonResults}
|
||||
renderItem={(item: PersonResult) => (
|
||||
<PersonPoster
|
||||
className='mr-2'
|
||||
@@ -3,9 +3,11 @@ import { useMemo } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
|
||||
const JellyseerrMediaIcon: React.FC<
|
||||
{ mediaType: "tv" | "movie" } & ViewProps
|
||||
> = ({ mediaType, className, ...props }) => {
|
||||
const SeerrMediaIcon: React.FC<{ mediaType: "tv" | "movie" } & ViewProps> = ({
|
||||
mediaType,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const style = useMemo(
|
||||
() =>
|
||||
mediaType === MediaType.MOVIE
|
||||
@@ -29,4 +31,4 @@ const JellyseerrMediaIcon: React.FC<
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyseerrMediaIcon;
|
||||
export default SeerrMediaIcon;
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
const SeerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
mediaStatus,
|
||||
showRequestIcon,
|
||||
onPress,
|
||||
@@ -74,4 +74,4 @@ const JellyseerrStatusIcon: React.FC<Props & ViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyseerrStatusIcon;
|
||||
export default SeerrStatusIcon;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import {
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
type Network,
|
||||
@@ -15,13 +16,14 @@ const CompanySlide: React.FC<
|
||||
{ data: Network[] | Studio[] } & SlideProps & ViewProps
|
||||
> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
({ id, image, name }: Network | Studio) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/company/${id}` as any,
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
|
||||
params: { id, image, name, type: slide.type },
|
||||
}),
|
||||
[slide],
|
||||
@@ -38,10 +40,7 @@ const CompanySlide: React.FC<
|
||||
<GenericSlideCard
|
||||
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
|
||||
id={item.id.toString()}
|
||||
url={jellyseerrApi?.imageProxy(
|
||||
item.image,
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
)}
|
||||
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -2,10 +2,10 @@ import { sortBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import CompanySlide from "@/components/jellyseerr/discover/CompanySlide";
|
||||
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
|
||||
import MovieTvSlide from "@/components/jellyseerr/discover/MovieTvSlide";
|
||||
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
|
||||
import CompanySlide from "@/components/seerr/discover/CompanySlide";
|
||||
import GenreSlide from "@/components/seerr/discover/GenreSlide";
|
||||
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
|
||||
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
|
||||
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||
@@ -1,39 +1,41 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
|
||||
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
(genre: GenreSliderItem) =>
|
||||
router.push({
|
||||
pathname: `/(auth)/(tabs)/${from}/jellyseerr/genre/${genre.id}` as any,
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
|
||||
params: { type: slide.type, name: genre.name },
|
||||
}),
|
||||
[slide],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.type, slide.id],
|
||||
queryKey: ["seerr", "discover", slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
return jellyseerrApi?.getGenreSliders(
|
||||
return seerrApi?.getGenreSliders(
|
||||
slide.type === DiscoverSliderType.MOVIE_GENRES
|
||||
? Endpoints.MOVIE
|
||||
: Endpoints.TV,
|
||||
);
|
||||
},
|
||||
enabled: !!jellyseerrApi,
|
||||
enabled: !!seerrApi,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -51,7 +53,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
title={item.name}
|
||||
colors={["transparent", "transparent"]}
|
||||
contentFit={"cover"}
|
||||
url={jellyseerrApi?.imageProxy(
|
||||
url={seerrApi?.imageProxy(
|
||||
item.backdrops?.[0],
|
||||
`w780_filter(duotone,${
|
||||
genreColorMap[item.id] ?? genreColorMap[0]
|
||||
@@ -3,23 +3,19 @@ import { uniqBy } from "lodash";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import {
|
||||
type DiscoverEndpoint,
|
||||
Endpoints,
|
||||
useJellyseerr,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
|
||||
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
slide,
|
||||
...props
|
||||
}) => {
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["jellyseerr", "discover", slide.id],
|
||||
queryKey: ["seerr", "discover", slide.id],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
let endpoint: DiscoverEndpoint | undefined;
|
||||
let params: any = {
|
||||
@@ -50,13 +46,13 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
break;
|
||||
}
|
||||
|
||||
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
|
||||
return endpoint ? seerrApi?.discover(endpoint, params) : null;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||
1,
|
||||
enabled: !!jellyseerrApi,
|
||||
enabled: !!seerrApi,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -65,9 +61,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
uniqBy(
|
||||
data?.pages
|
||||
?.filter((p) => p?.results.length)
|
||||
.flatMap((p) =>
|
||||
p?.results.filter((r) => isJellyseerrMovieOrTvResult(r)),
|
||||
),
|
||||
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
|
||||
"id",
|
||||
),
|
||||
[data],
|
||||
@@ -84,7 +78,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
onEndReached={() => {
|
||||
if (hasNextPage) fetchNextPage();
|
||||
}}
|
||||
renderItem={(item) => <JellyseerrPoster item={item} key={item?.id} />}
|
||||
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type React from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import SeerrPoster from "@/components/posters/SeerrPoster";
|
||||
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
|
||||
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
|
||||
@@ -16,36 +16,36 @@ type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
||||
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
|
||||
request,
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
|
||||
const { data: details } = useQuery({
|
||||
queryKey: [
|
||||
"jellyseerr",
|
||||
"seerr",
|
||||
"detail",
|
||||
request.media.mediaType,
|
||||
request.media.tmdbId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
return request.media.mediaType === MediaType.MOVIE
|
||||
? jellyseerrApi?.movieDetails(request.media.tmdbId)
|
||||
: jellyseerrApi?.tvDetails(request.media.tmdbId);
|
||||
? seerrApi?.movieDetails(request.media.tmdbId)
|
||||
: seerrApi?.tvDetails(request.media.tmdbId);
|
||||
},
|
||||
enabled: !!jellyseerrApi,
|
||||
enabled: !!seerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: refreshedRequest } = useQuery({
|
||||
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
|
||||
queryFn: async () => jellyseerrApi?.getRequest(request.id),
|
||||
enabled: !!jellyseerrApi,
|
||||
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
|
||||
queryFn: async () => seerrApi?.getRequest(request.id),
|
||||
enabled: !!seerrApi,
|
||||
refetchOnMount: true,
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<JellyseerrPoster
|
||||
<SeerrPoster
|
||||
horizontal
|
||||
showDownloadInfo
|
||||
item={details}
|
||||
@@ -58,12 +58,12 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
slide,
|
||||
...props
|
||||
}) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
|
||||
const { data: requests } = useQuery({
|
||||
queryKey: ["jellyseerr", "recent_requests"],
|
||||
queryFn: async () => jellyseerrApi?.requests(),
|
||||
enabled: !!jellyseerrApi,
|
||||
queryKey: ["seerr", "recent_requests"],
|
||||
queryFn: async () => seerrApi?.requests(),
|
||||
enabled: !!seerrApi,
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
@@ -2,12 +2,17 @@ import type {
|
||||
BaseItemDto,
|
||||
BaseItemPerson,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -23,6 +28,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const segments = useSegments();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2];
|
||||
|
||||
const destinctPeople = useMemo(() => {
|
||||
@@ -50,7 +56,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
<HorizontalScroll
|
||||
loading={loading}
|
||||
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
|
||||
height={247}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
data={destinctPeople}
|
||||
renderItem={(i) => (
|
||||
<TouchableOpacity
|
||||
@@ -65,8 +71,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
||||
className='flex flex-col w-28'
|
||||
>
|
||||
<Poster id={i.Id} url={getPrimaryImageUrl({ api, item: i })} />
|
||||
<Text className='mt-2'>{i.Name}</Text>
|
||||
<Text className='text-xs opacity-50'>{i.Role}</Text>
|
||||
<Text className='mt-2' numberOfLines={1}>
|
||||
{i.Name}
|
||||
</Text>
|
||||
<Text className='text-xs opacity-50' numberOfLines={1}>
|
||||
{i.Role}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
|
||||
const POSTER_CAROUSEL_HEIGHT = 220;
|
||||
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -17,6 +21,7 @@ interface Props extends ViewProps {
|
||||
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<View {...props}>
|
||||
@@ -25,7 +30,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
</Text>
|
||||
<HorizontalScroll
|
||||
data={[item]}
|
||||
height={247}
|
||||
height={POSTER_CAROUSEL_HEIGHT}
|
||||
renderItem={(item, _index) => (
|
||||
<TouchableOpacity
|
||||
key={item?.Id}
|
||||
@@ -38,7 +43,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
|
||||
id={item?.Id}
|
||||
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
|
||||
/>
|
||||
<Text>{item?.SeriesName}</Text>
|
||||
<Text numberOfLines={1}>{item?.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { Button } from "../Button";
|
||||
|
||||
|
||||
@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC<Props> = ({
|
||||
[state, item, keys],
|
||||
);
|
||||
|
||||
// Always use IndexNumber for Season objects (not keys.index which is for the item)
|
||||
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
|
||||
Number(a[keys.index]) - Number(b[keys.index]);
|
||||
Number(a.IndexNumber) - Number(b.IndexNumber);
|
||||
|
||||
const optionGroups = useMemo(
|
||||
() => [
|
||||
{
|
||||
options:
|
||||
seasons?.sort(sortByIndex).map((season: any) => {
|
||||
const title =
|
||||
season[keys.title] ||
|
||||
season.Name ||
|
||||
`Season ${season.IndexNumber}`;
|
||||
const title = season.Name || `Season ${season.IndexNumber}`;
|
||||
return {
|
||||
type: "radio" as const,
|
||||
label: title,
|
||||
value: season.Id || season.IndexNumber,
|
||||
selected: Number(season[keys.index]) === Number(seasonIndex),
|
||||
// Compare season's IndexNumber with the selected seasonIndex
|
||||
selected: Number(season.IndexNumber) === Number(seasonIndex),
|
||||
onPress: () => onSelect(season),
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
],
|
||||
[seasons, keys, seasonIndex, onSelect],
|
||||
[seasons, seasonIndex, onSelect],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, type ViewStyle } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
|
||||
interface Props {
|
||||
item?: BaseItemDto | null;
|
||||
loading?: boolean;
|
||||
isOffline?: boolean;
|
||||
style?: ViewStyle;
|
||||
containerStyle?: ViewStyle;
|
||||
}
|
||||
@@ -25,17 +26,14 @@ interface Props {
|
||||
export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
item,
|
||||
loading,
|
||||
isOffline,
|
||||
style,
|
||||
containerStyle,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
|
||||
const scrollRef = useRef<HorizontalScrollRef>(null);
|
||||
|
||||
@@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
queryKey: ["episodes", seasonId, isOffline],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
|
||||
)
|
||||
.map((f) => f.item);
|
||||
return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
|
||||
}
|
||||
if (!api || !user?.Id || !item?.SeriesId) return [];
|
||||
const response = await getTvShowsApi(api).getEpisodes({
|
||||
@@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
|
||||
});
|
||||
return response.data.Items as BaseItemDto[];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!seasonId,
|
||||
enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,7 +10,13 @@ import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import {
|
||||
buildOfflineSeasons,
|
||||
getDownloadedEpisodesForSeason,
|
||||
} from "@/utils/downloads/offline-series";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
const { t } = useTranslation();
|
||||
const isOffline = useOfflineMode();
|
||||
const { getDownloadedItems, downloadedItems } = useDownload();
|
||||
|
||||
const seasonIndex = useMemo(
|
||||
() => seasonIndexState[item.Id ?? ""],
|
||||
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
);
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.Id}/Seasons`,
|
||||
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
|
||||
return response.data.Items;
|
||||
},
|
||||
staleTime: 60,
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
staleTime: isOffline ? Infinity : 60,
|
||||
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(() => {
|
||||
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
return season.Id!;
|
||||
}, [seasons, seasonIndex]);
|
||||
|
||||
// For offline mode, we use season index number instead of ID
|
||||
const selectedSeasonNumber = useMemo(() => {
|
||||
if (!isOffline) return null;
|
||||
const season = seasons?.find(
|
||||
(s: BaseItemDto) =>
|
||||
s.IndexNumber === seasonIndex || s.Name === seasonIndex,
|
||||
);
|
||||
return season?.IndexNumber ?? null;
|
||||
}, [isOffline, seasons, seasonIndex]);
|
||||
|
||||
const { data: episodes, isPending } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryKey: [
|
||||
"episodes",
|
||||
item.Id,
|
||||
isOffline ? selectedSeasonNumber : selectedSeasonId,
|
||||
isOffline,
|
||||
downloadedItems.length,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
return getDownloadedEpisodesForSeason(
|
||||
getDownloadedItems(),
|
||||
item.Id!,
|
||||
selectedSeasonNumber!,
|
||||
);
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
|
||||
return [];
|
||||
}
|
||||
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
userId: user.Id,
|
||||
seasonId: selectedSeasonId,
|
||||
enableUserData: true,
|
||||
// Note: Including trick play is necessary to enable trick play downloads
|
||||
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||
});
|
||||
|
||||
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
|
||||
return res.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
staleTime: isOffline ? Infinity : 0,
|
||||
enabled: isOffline
|
||||
? !!item.Id && selectedSeasonNumber !== null
|
||||
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
// Used for height calculation
|
||||
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{episodes?.length ? (
|
||||
{episodes?.length && !isOffline ? (
|
||||
<View className='flex flex-row items-center space-x-2'>
|
||||
<DownloadItems
|
||||
title={t("item_card.download.download_season")}
|
||||
@@ -180,9 +218,11 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='self-start ml-auto -mt-0.5'>
|
||||
<DownloadSingleItem item={e} />
|
||||
</View>
|
||||
{!isOffline && (
|
||||
<View className='self-start ml-auto -mt-0.5'>
|
||||
<DownloadSingleItem item={e} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
|
||||
@@ -14,11 +14,11 @@ import { Alert, TouchableOpacity, View } from "react-native";
|
||||
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Tags } from "@/components/GenreTags";
|
||||
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
|
||||
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
|
||||
import { RoundButton } from "@/components/RoundButton";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { dateOpts } from "@/components/seerr/DetailFacts";
|
||||
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
|
||||
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
|
||||
import { useSeerr } from "@/hooks/useSeerr";
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaType,
|
||||
@@ -30,15 +30,15 @@ import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
const JellyseerrSeasonEpisodes: React.FC<{
|
||||
const SeerrSeasonEpisodes: React.FC<{
|
||||
details: TvDetails;
|
||||
seasonNumber: number;
|
||||
}> = ({ details, seasonNumber }) => {
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { seerrApi } = useSeerr();
|
||||
|
||||
const { data: seasonWithEpisodes, isLoading } = useQuery({
|
||||
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
|
||||
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
|
||||
queryKey: ["seerr", details.id, "season", seasonNumber],
|
||||
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
|
||||
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
|
||||
});
|
||||
|
||||
@@ -57,11 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
|
||||
};
|
||||
|
||||
const RenderItem = ({ item }: any) => {
|
||||
const {
|
||||
jellyseerrApi,
|
||||
jellyseerrRegion: region,
|
||||
jellyseerrLocale: locale,
|
||||
} = useJellyseerr();
|
||||
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const upcomingAirDate = useMemo(() => {
|
||||
@@ -83,7 +79,7 @@ const RenderItem = ({ item }: any) => {
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
source={{
|
||||
uri: jellyseerrApi?.imageProxy(item.stillPath),
|
||||
uri: seerrApi?.imageProxy(item.stillPath),
|
||||
}}
|
||||
cachePolicy={"memory-disk"}
|
||||
contentFit='cover'
|
||||
@@ -131,7 +127,7 @@ const RenderItem = ({ item }: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
const JellyseerrSeasons: React.FC<{
|
||||
const SeerrSeasons: React.FC<{
|
||||
isLoading: boolean;
|
||||
details?: TvDetails;
|
||||
hasAdvancedRequest?: boolean;
|
||||
@@ -148,7 +144,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
hasAdvancedRequest,
|
||||
onAdvancedRequest,
|
||||
}) => {
|
||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||
const { seerrApi, requestMedia } = useSeerr();
|
||||
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
|
||||
{},
|
||||
);
|
||||
@@ -181,7 +177,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
);
|
||||
|
||||
const requestAll = useCallback(() => {
|
||||
if (details && jellyseerrApi) {
|
||||
if (details && seerrApi) {
|
||||
const body: MediaRequestBody = {
|
||||
mediaId: details.id,
|
||||
mediaType: MediaType.TV,
|
||||
@@ -198,7 +194,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
requestMedia(details.name, body, refetch);
|
||||
}
|
||||
}, [
|
||||
jellyseerrApi,
|
||||
seerrApi,
|
||||
seasons,
|
||||
details,
|
||||
hasAdvancedRequest,
|
||||
@@ -210,15 +206,15 @@ const JellyseerrSeasons: React.FC<{
|
||||
const promptRequestAll = useCallback(
|
||||
() =>
|
||||
Alert.alert(
|
||||
t("jellyseerr.confirm"),
|
||||
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
|
||||
t("seerr.confirm"),
|
||||
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
|
||||
[
|
||||
{
|
||||
text: t("jellyseerr.cancel"),
|
||||
text: t("seerr.cancel"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("jellyseerr.yes"),
|
||||
text: t("seerr.yes"),
|
||||
onPress: requestAll,
|
||||
},
|
||||
],
|
||||
@@ -301,10 +297,10 @@ const JellyseerrSeasons: React.FC<{
|
||||
<Tags
|
||||
textClass=''
|
||||
tags={[
|
||||
t("jellyseerr.season_number", {
|
||||
t("seerr.season_number", {
|
||||
season_number: season.seasonNumber,
|
||||
}),
|
||||
t("jellyseerr.number_episodes", {
|
||||
t("seerr.number_episodes", {
|
||||
episode_number: season.episodeCount,
|
||||
}),
|
||||
]}
|
||||
@@ -312,7 +308,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
{[0].map(() => {
|
||||
const canRequest = season.status === MediaStatus.UNKNOWN;
|
||||
return (
|
||||
<JellyseerrStatusIcon
|
||||
<SeerrStatusIcon
|
||||
key={0}
|
||||
onPress={() =>
|
||||
requestSeason(canRequest, season.seasonNumber)
|
||||
@@ -326,7 +322,7 @@ const JellyseerrSeasons: React.FC<{
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{seasonStates?.[season.seasonNumber] && (
|
||||
<JellyseerrSeasonEpisodes
|
||||
<SeerrSeasonEpisodes
|
||||
key={season.seasonNumber}
|
||||
details={details}
|
||||
seasonNumber={season.seasonNumber}
|
||||
@@ -338,4 +334,4 @@ const JellyseerrSeasons: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyseerrSeasons;
|
||||
export default SeerrSeasons;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch } from "react-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -50,6 +50,16 @@ export const AppearanceSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.merge_next_up_continue_watching")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.mergeNextUpAndContinueWatching}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ mergeNextUpAndContinueWatching: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/settings/appearance/hide-libraries/page")
|
||||
@@ -57,6 +67,16 @@ export const AppearanceSettings: React.FC = () => {
|
||||
title={t("home.settings.other.hide_libraries")}
|
||||
showArrow
|
||||
/>
|
||||
<ListItem
|
||||
title={t("home.settings.appearance.hide_remote_session_button")}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideRemoteSessionButton}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({ hideRemoteSessionButton: value })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View, type ViewProps } from "react-native";
|
||||
import { Switch } from "react-native-gesture-handler";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -54,6 +54,70 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
];
|
||||
}, [cultures, settings?.defaultAudioLanguage, t, updateSettings]);
|
||||
|
||||
const audioTranscodeModeLabels: Record<AudioTranscodeMode, string> = {
|
||||
[AudioTranscodeMode.Auto]: t("home.settings.audio.transcode_mode.auto"),
|
||||
[AudioTranscodeMode.ForceStereo]: t(
|
||||
"home.settings.audio.transcode_mode.stereo",
|
||||
),
|
||||
[AudioTranscodeMode.Allow51]: t("home.settings.audio.transcode_mode.5_1"),
|
||||
[AudioTranscodeMode.AllowAll]: t(
|
||||
"home.settings.audio.transcode_mode.passthrough",
|
||||
),
|
||||
};
|
||||
|
||||
const audioTranscodeModeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
options: [
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.auto"),
|
||||
value: AudioTranscodeMode.Auto,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.Auto ||
|
||||
!settings?.audioTranscodeMode,
|
||||
onPress: () =>
|
||||
updateSettings({ audioTranscodeMode: AudioTranscodeMode.Auto }),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.stereo"),
|
||||
value: AudioTranscodeMode.ForceStereo,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.ForceStereo,
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
audioTranscodeMode: AudioTranscodeMode.ForceStereo,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.5_1"),
|
||||
value: AudioTranscodeMode.Allow51,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.Allow51,
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
audioTranscodeMode: AudioTranscodeMode.Allow51,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "radio" as const,
|
||||
label: t("home.settings.audio.transcode_mode.passthrough"),
|
||||
value: AudioTranscodeMode.AllowAll,
|
||||
selected:
|
||||
settings?.audioTranscodeMode === AudioTranscodeMode.AllowAll,
|
||||
onPress: () =>
|
||||
updateSettings({
|
||||
audioTranscodeMode: AudioTranscodeMode.AllowAll,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[settings?.audioTranscodeMode, t, updateSettings],
|
||||
);
|
||||
|
||||
if (isTv) return null;
|
||||
if (!settings) return null;
|
||||
|
||||
@@ -98,6 +162,31 @@ export const AudioToggles: React.FC<Props> = ({ ...props }) => {
|
||||
title={t("home.settings.audio.language")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.audio.transcode_mode.title")}
|
||||
subtitle={t("home.settings.audio.transcode_mode.description")}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={audioTranscodeModeOptions}
|
||||
trigger={
|
||||
<View className='flex flex-row items-center justify-between py-1.5 pl-3'>
|
||||
<Text className='mr-1 text-[#8E8D91]'>
|
||||
{
|
||||
audioTranscodeModeLabels[
|
||||
settings?.audioTranscodeMode || AudioTranscodeMode.Auto
|
||||
]
|
||||
}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name='chevron-expand-sharp'
|
||||
size={18}
|
||||
color='#5A5960'
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
title={t("home.settings.audio.transcode_mode.title")}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { settings } = useSettings();
|
||||
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!settings) return null;
|
||||
return (
|
||||
<View>
|
||||
<ListGroup title={t("home.settings.dashboard.title")} className='mt-4'>
|
||||
<ListItem
|
||||
className={sessions.length !== 0 ? "bg-purple-900" : ""}
|
||||
onPress={() => router.push("/settings/dashboard/sessions")}
|
||||
title={t("home.settings.dashboard.sessions_title")}
|
||||
showArrow
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -11,12 +11,12 @@ const DisabledSetting: React.FC<
|
||||
}}
|
||||
>
|
||||
<View {...props}>
|
||||
{children}
|
||||
{disabled && showText && (
|
||||
<Text className='text-center text-red-700 my-4'>
|
||||
{text ?? "Currently disabled by admin."}
|
||||
<Text className='text-xs text-red-600 px-4 mt-1'>
|
||||
{text ?? "Disabled by admin"}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -19,7 +19,9 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
() =>
|
||||
pluginSettings?.enableHorizontalSwipeSkip?.locked === true &&
|
||||
pluginSettings?.enableLeftSideBrightnessSwipe?.locked === true &&
|
||||
pluginSettings?.enableRightSideVolumeSwipe?.locked === true,
|
||||
pluginSettings?.enableRightSideVolumeSwipe?.locked === true &&
|
||||
pluginSettings?.hideVolumeSlider?.locked === true &&
|
||||
pluginSettings?.hideBrightnessSlider?.locked === true,
|
||||
[pluginSettings],
|
||||
);
|
||||
|
||||
@@ -77,6 +79,38 @@ export const GestureControls: React.FC<Props> = ({ ...props }) => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.gesture_controls.hide_volume_slider")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.hide_volume_slider_description",
|
||||
)}
|
||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideVolumeSlider}
|
||||
disabled={pluginSettings?.hideVolumeSlider?.locked}
|
||||
onValueChange={(hideVolumeSlider) =>
|
||||
updateSettings({ hideVolumeSlider })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.gesture_controls.hide_brightness_slider")}
|
||||
subtitle={t(
|
||||
"home.settings.gesture_controls.hide_brightness_slider_description",
|
||||
)}
|
||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||
>
|
||||
<Switch
|
||||
value={settings.hideBrightnessSlider}
|
||||
disabled={pluginSettings?.hideBrightnessSlider?.locked}
|
||||
onValueChange={(hideBrightnessSlider) =>
|
||||
updateSettings({ hideBrightnessSlider })
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</ListGroup>
|
||||
</DisabledSetting>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user