mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 19:48:28 +01:00
Compare commits
2 Commits
fix/tv-sea
...
cleanup/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13973dc53a | ||
|
|
8eeb571f33 |
@@ -143,14 +143,6 @@ interface ModalOptions {
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See `components/ExampleGlobalModalUsage.tsx` for comprehensive examples including:
|
||||
- Simple content modal
|
||||
- Modal with custom snap points
|
||||
- Complex component in modal
|
||||
- Success/error modals triggered from functions
|
||||
|
||||
## Default Styling
|
||||
|
||||
The modal uses these default styles (can be overridden via options):
|
||||
|
||||
@@ -59,19 +59,17 @@ function SettingsMobile() {
|
||||
|
||||
<QuickConnect className='mb-4' />
|
||||
|
||||
{Platform.OS !== "ios" && (
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
)}
|
||||
<View className='mb-4'>
|
||||
<ListGroup title={t("pairing.pair_with_phone_title")}>
|
||||
<ListItem
|
||||
onPress={() =>
|
||||
router.push("/(auth)/(tabs)/(home)/companion-login")
|
||||
}
|
||||
title={t("pairing.pair_with_phone")}
|
||||
textColor='blue'
|
||||
/>
|
||||
</ListGroup>
|
||||
</View>
|
||||
|
||||
<View className='mb-4'>
|
||||
<AppLanguageSelector />
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function StreamystatsPage() {
|
||||
};
|
||||
|
||||
const handleRefreshFromServer = useCallback(async () => {
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
const newPluginSettings = await refreshStreamyfinPluginSettings(true);
|
||||
// Update local state with new values
|
||||
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
|
||||
setUrl(newUrl);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./mmkv";
|
||||
export * from "./number";
|
||||
export * from "./string";
|
||||
|
||||
@@ -3,7 +3,6 @@ declare global {
|
||||
bytesToReadable(decimals?: number): string;
|
||||
secondsToMilliseconds(): number;
|
||||
minutesToMilliseconds(): number;
|
||||
hoursToMilliseconds(): number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
|
||||
return this.valueOf() * (60).secondsToMilliseconds();
|
||||
};
|
||||
|
||||
Number.prototype.hoursToMilliseconds = function () {
|
||||
return this.valueOf() * (60).minutesToMilliseconds();
|
||||
};
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
declare global {
|
||||
interface String {
|
||||
toTitle(): string;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toTitle = function () {
|
||||
return this.replaceAll("_", " ").replace(
|
||||
/\w\S*/g,
|
||||
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||
);
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -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,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>
|
||||
);
|
||||
};
|
||||
@@ -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,6 +1,6 @@
|
||||
import { t } from "i18next";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Platform, ScrollView, View } from "react-native";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
@@ -107,7 +107,7 @@ export const TVAddServerForm: React.FC<TVAddServerFormProps> = ({
|
||||
</View>
|
||||
|
||||
{/* Pair with Phone */}
|
||||
{Platform.OS !== "ios" && onStartPairing && (
|
||||
{onStartPairing && (
|
||||
<View>
|
||||
<Button
|
||||
onPress={onStartPairing}
|
||||
|
||||
@@ -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;
|
||||
@@ -401,6 +401,10 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasMovies = movieResults && movieResults.length > 0;
|
||||
const hasTv = tvResults && tvResults.length > 0;
|
||||
const hasPersons = personResults && personResults.length > 0;
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
@@ -427,26 +431,22 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* No section requests `hasTVPreferredFocus`: the native search field
|
||||
keeps focus while typing, otherwise the first result would re-grab
|
||||
focus on every keystroke as results re-render. The user navigates
|
||||
down to the grid manually. */}
|
||||
<TVJellyseerrMovieSection
|
||||
title={t("search.request_movies")}
|
||||
items={movieResults}
|
||||
isFirstSection={false}
|
||||
isFirstSection={hasMovies}
|
||||
onItemPress={onMoviePress}
|
||||
/>
|
||||
<TVJellyseerrTvSection
|
||||
title={t("search.request_series")}
|
||||
items={tvResults}
|
||||
isFirstSection={false}
|
||||
isFirstSection={!hasMovies && hasTv}
|
||||
onItemPress={onTvPress}
|
||||
/>
|
||||
<TVJellyseerrPersonSection
|
||||
title={t("search.actors")}
|
||||
items={personResults}
|
||||
isFirstSection={false}
|
||||
isFirstSection={!hasMovies && !hasTv && hasPersons}
|
||||
onItemPress={onPersonPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -235,13 +235,10 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
module). It renders the native search bar + grid keyboard and
|
||||
forwards typed text into the existing query pipeline via setSearch;
|
||||
our own results grid renders below. */}
|
||||
{/* No horizontal margin here: the native tvOS search bar centers itself
|
||||
and renders a trailing "Hold to Dictate in <Language>" hint. Extra
|
||||
margins squeeze the bar's width and clip that trailing hint, so let
|
||||
the native view span the full width and own its own insets. */}
|
||||
<View
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
height: SEARCH_AREA_HEIGHT,
|
||||
}}
|
||||
>
|
||||
@@ -283,17 +280,13 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
{/* Library Search Results */}
|
||||
{isLibraryMode && !loading && (
|
||||
<View style={{ gap: SECTION_GAP }}>
|
||||
{sections.map((section) => (
|
||||
{sections.map((section, index) => (
|
||||
<TVSearchSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
items={section.items!}
|
||||
orientation={section.orientation || "vertical"}
|
||||
// Never auto-focus a result. The native search field owns focus
|
||||
// while typing; `hasTVPreferredFocus` here would re-grab focus on
|
||||
// every keystroke as results re-render. User navigates down to the
|
||||
// grid manually.
|
||||
isFirstSection={false}
|
||||
isFirstSection={index === 0}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
|
||||
@@ -297,12 +297,12 @@ export const TVSearchSection: React.FC<TVSearchSectionProps> = ({
|
||||
removeClippedSubviews={false}
|
||||
getItemLayout={getItemLayout}
|
||||
style={{ overflow: "visible" }}
|
||||
// Edge padding via contentContainerStyle, NOT contentInset+contentOffset.
|
||||
// contentOffset only applies on initial mount; since this FlatList is
|
||||
// reused across searches (stable key), a second search left the inset
|
||||
// without the offset and the grid snapped flush to the left edge.
|
||||
contentInset={{
|
||||
left: edgePadding,
|
||||
right: edgePadding,
|
||||
}}
|
||||
contentOffset={{ x: -edgePadding, y: 0 }}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: edgePadding,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function DownloadSettings() {
|
||||
return null;
|
||||
}
|
||||
@@ -196,10 +196,7 @@ export const OtherSettings: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={pluginSettings?.maxAutoPlayEpisodeCount?.locked}
|
||||
>
|
||||
<ListItem title={t("home.settings.other.max_auto_play_episode_count")}>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
trigger={
|
||||
|
||||
@@ -229,10 +229,7 @@ export const PlaybackControlsSettings: React.FC = () => {
|
||||
|
||||
<ListItem
|
||||
title={t("home.settings.other.max_auto_play_episode_count")}
|
||||
disabled={
|
||||
!settings.autoPlayNextEpisode ||
|
||||
pluginSettings?.maxAutoPlayEpisodeCount?.locked
|
||||
}
|
||||
disabled={!settings.autoPlayNextEpisode}
|
||||
>
|
||||
<PlatformDropdown
|
||||
groups={autoPlayEpisodeOptions}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { DefaultLanguageOption } from "@/utils/atoms/settings";
|
||||
|
||||
export const LANGUAGES: DefaultLanguageOption[] = [
|
||||
{ label: "English", value: "eng" },
|
||||
{ label: "Spanish", value: "spa" },
|
||||
{ label: "Chinese (Mandarin)", value: "cmn" },
|
||||
{ label: "Hindi", value: "hin" },
|
||||
{ label: "Arabic", value: "ara" },
|
||||
{ label: "French", value: "fra" },
|
||||
{ label: "Russian", value: "rus" },
|
||||
{ label: "Portuguese", value: "por" },
|
||||
{ label: "Japanese", value: "jpn" },
|
||||
{ label: "German", value: "deu" },
|
||||
{ label: "Italian", value: "ita" },
|
||||
{ label: "Korean", value: "kor" },
|
||||
{ label: "Turkish", value: "tur" },
|
||||
{ label: "Dutch", value: "nld" },
|
||||
{ label: "Polish", value: "pol" },
|
||||
{ label: "Vietnamese", value: "vie" },
|
||||
{ label: "Thai", value: "tha" },
|
||||
{ label: "Indonesian", value: "ind" },
|
||||
{ label: "Greek", value: "ell" },
|
||||
{ label: "Swedish", value: "swe" },
|
||||
{ label: "Danish", value: "dan" },
|
||||
{ label: "Norwegian", value: "nor" },
|
||||
{ label: "Finnish", value: "fin" },
|
||||
{ label: "Czech", value: "ces" },
|
||||
{ label: "Hungarian", value: "hun" },
|
||||
{ label: "Romanian", value: "ron" },
|
||||
{ label: "Ukrainian", value: "ukr" },
|
||||
{ label: "Hebrew", value: "heb" },
|
||||
{ label: "Bengali", value: "ben" },
|
||||
{ label: "Punjabi", value: "pan" },
|
||||
{ label: "Tagalog", value: "tgl" },
|
||||
{ label: "Swahili", value: "swa" },
|
||||
{ label: "Malay", value: "msa" },
|
||||
{ label: "Persian", value: "fas" },
|
||||
{ label: "Urdu", value: "urd" },
|
||||
];
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
export const useControlsVisibility = (timeout = 3000) => {
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
opacity.value = 1;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
hideControlsTimerRef.current = setTimeout(() => {
|
||||
opacity.value = 0;
|
||||
}, timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
opacity.value = 0;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { opacity, showControls, hideControls };
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
},
|
||||
[setOfflineSettings, setPlayUrl, router],
|
||||
);
|
||||
|
||||
return { openFile };
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type * as ImageColorsType from "react-native-image-colors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||
const ImageColors = Platform.isTV
|
||||
? null
|
||||
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||
|
||||
import {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
isCloseToBlack,
|
||||
itemThemeColorAtom,
|
||||
} from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = ({
|
||||
item,
|
||||
url,
|
||||
disabled,
|
||||
}: {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (url) return { uri: url };
|
||||
if (item)
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
return null;
|
||||
}, [api, item, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (disabled) return;
|
||||
if (source?.uri) {
|
||||
const _primary = storage.getString(`${source.uri}-primary`);
|
||||
const _text = storage.getString(`${source.uri}-text`);
|
||||
|
||||
if (_primary && _text) {
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.detail;
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||
|
||||
if (isTv) return;
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type SortOrder,
|
||||
SubtitlePlaybackMode,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { t } from "i18next";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
|
||||
@@ -122,46 +121,6 @@ export interface MaxAutoPlayEpisodeCount {
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin may send object-typed settings as plain primitives.
|
||||
* Resolve to the proper option object from the available choices.
|
||||
*/
|
||||
const normalizePluginValue = (
|
||||
settingsKey: keyof Settings,
|
||||
value: unknown,
|
||||
): unknown => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
const defaultVal = defaultValues[settingsKey];
|
||||
if (
|
||||
typeof defaultVal === "object" &&
|
||||
defaultVal !== null &&
|
||||
"key" in defaultVal &&
|
||||
"value" in defaultVal
|
||||
) {
|
||||
// defaultBitrate needs a lookup because its keys are human-readable
|
||||
// (e.g. "8 Mb/s") that can't be derived from the raw value (e.g. 8000000).
|
||||
// Other { key, value } settings like maxAutoPlayEpisodeCount work with
|
||||
// the fallback because their keys are just String(value) (e.g. "5").
|
||||
if (settingsKey === "defaultBitrate") {
|
||||
const match = BITRATES.find(
|
||||
(b) => b.key === value || b.value === value,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
// maxAutoPlayEpisodeCount: 0 is invalid (breaks autoplay), clamp to -1
|
||||
// -1 key must match the translated dropdown label so the UI shows "Disabled"
|
||||
if (
|
||||
settingsKey === "maxAutoPlayEpisodeCount" &&
|
||||
(value === 0 || value === -1)
|
||||
) {
|
||||
return { key: t("home.settings.other.disabled"), value: -1 };
|
||||
}
|
||||
return { key: String(value), value };
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export type HomeSectionLatestResolver = {
|
||||
parentId?: string;
|
||||
limit?: number;
|
||||
@@ -468,37 +427,61 @@ export const useSettings = () => {
|
||||
[_setPluginSettings],
|
||||
);
|
||||
|
||||
const refreshStreamyfinPluginSettings = useCallback(async () => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
return data?.settings;
|
||||
},
|
||||
(_err) => undefined,
|
||||
);
|
||||
setPluginSettings(newPluginSettings);
|
||||
|
||||
// Locked/unlocked values are handled by the settings memo, which
|
||||
// applies locked values at runtime without overwriting user storage.
|
||||
// We only handle auto-enabling Streamystats here.
|
||||
if (newPluginSettings && _settings) {
|
||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||
if (streamyStatsUrl?.value && _settings.searchEngine !== "Streamystats") {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
searchEngine: "Streamystats",
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
const refreshStreamyfinPluginSettings = useCallback(
|
||||
async (forceOverride = false) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const newPluginSettings = await api.getStreamyfinPluginConfig().then(
|
||||
({ data }) => {
|
||||
writeInfoLog("Got plugin settings", data?.settings);
|
||||
return data?.settings;
|
||||
},
|
||||
(_err) => undefined,
|
||||
);
|
||||
setPluginSettings(newPluginSettings);
|
||||
|
||||
return newPluginSettings;
|
||||
}, [api, _settings]);
|
||||
// Apply plugin values to settings
|
||||
if (newPluginSettings && _settings) {
|
||||
const updates: Partial<Settings> = {};
|
||||
for (const [key, setting] of Object.entries(newPluginSettings)) {
|
||||
if (setting && !setting.locked && setting.value !== undefined) {
|
||||
const settingsKey = key as keyof Settings;
|
||||
const effectiveValue = getEffectiveSettingValue(
|
||||
_settings,
|
||||
settingsKey,
|
||||
);
|
||||
// Apply if forceOverride is true, or if neither persisted settings
|
||||
// nor app defaults provide a meaningful value.
|
||||
if (forceOverride || !hasMeaningfulSettingValue(effectiveValue)) {
|
||||
(updates as any)[settingsKey] = setting.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-enable Streamystats if server URL is provided
|
||||
const streamyStatsUrl = newPluginSettings.streamyStatsServerUrl;
|
||||
if (
|
||||
streamyStatsUrl?.value &&
|
||||
_settings.searchEngine !== "Streamystats"
|
||||
) {
|
||||
updates.searchEngine = "Streamystats";
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const newSettings = {
|
||||
...defaultValues,
|
||||
..._settings,
|
||||
...updates,
|
||||
} as Settings;
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
}
|
||||
|
||||
return newPluginSettings;
|
||||
},
|
||||
[api, _settings],
|
||||
);
|
||||
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (!_settings) {
|
||||
@@ -529,13 +512,8 @@ export const useSettings = () => {
|
||||
Partial<Settings>
|
||||
>((acc, [key, setting]) => {
|
||||
if (setting) {
|
||||
let { value } = setting;
|
||||
const { locked } = setting;
|
||||
const { value, locked } = setting;
|
||||
const settingsKey = key as keyof Settings;
|
||||
|
||||
// Normalize object-typed settings from plugin (plain primitive → { key, value })
|
||||
value = normalizePluginValue(settingsKey, value);
|
||||
|
||||
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
|
||||
|
||||
(acc as any)[settingsKey] = locked
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Convert bits to megabits or gigabits
|
||||
*
|
||||
* Return nice looking string
|
||||
* If under 1000Mb, return XXXMB, else return X.XGB
|
||||
*/
|
||||
|
||||
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
|
||||
if (!bits) return "0MB";
|
||||
|
||||
const megabits = bits / 1000000;
|
||||
|
||||
if (megabits < 1000) {
|
||||
return `${Math.round(megabits)}MB`;
|
||||
}
|
||||
const gigabits = megabits / 1000;
|
||||
return `${gigabits.toFixed(1)}GB`;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
BaseItemKind,
|
||||
CollectionType,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
|
||||
/**
|
||||
* Converts a ColletionType to a BaseItemKind (also called ItemType)
|
||||
*
|
||||
* CollectionTypes
|
||||
* readonly Unknown: "unknown";
|
||||
readonly Movies: "movies";
|
||||
readonly Tvshows: "tvshows";
|
||||
readonly Trailers: "trailers";
|
||||
readonly Homevideos: "homevideos";
|
||||
readonly Boxsets: "boxsets";
|
||||
readonly Books: "books";
|
||||
readonly Photos: "photos";
|
||||
readonly Livetv: "livetv";
|
||||
readonly Playlists: "playlists";
|
||||
readonly Folders: "folders";
|
||||
*/
|
||||
export const colletionTypeToItemType = (
|
||||
collectionType?: CollectionType | null,
|
||||
): BaseItemKind | undefined => {
|
||||
if (!collectionType) return undefined;
|
||||
|
||||
switch (collectionType) {
|
||||
case CollectionType.Movies:
|
||||
return BaseItemKind.Movie;
|
||||
case CollectionType.Tvshows:
|
||||
return BaseItemKind.Series;
|
||||
case CollectionType.Homevideos:
|
||||
return BaseItemKind.Video;
|
||||
case CollectionType.Books:
|
||||
return BaseItemKind.Book;
|
||||
case CollectionType.Playlists:
|
||||
return BaseItemKind.Playlist;
|
||||
case CollectionType.Folders:
|
||||
return BaseItemKind.Folder;
|
||||
case CollectionType.Photos:
|
||||
return BaseItemKind.Photo;
|
||||
case CollectionType.Trailers:
|
||||
return BaseItemKind.Trailer;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
export interface SubtitleTrack {
|
||||
index: number;
|
||||
name: string;
|
||||
uri: string;
|
||||
language: string;
|
||||
default: boolean;
|
||||
forced: boolean;
|
||||
autoSelect: boolean;
|
||||
}
|
||||
|
||||
export async function parseM3U8ForSubtitles(
|
||||
url: string,
|
||||
): Promise<SubtitleTrack[]> {
|
||||
try {
|
||||
const response = await axios.get(url, { responseType: "text" });
|
||||
const lines = response.data.split(/\r?\n/);
|
||||
const subtitleTracks: SubtitleTrack[] = [];
|
||||
let index = 0;
|
||||
|
||||
lines.forEach((line: string) => {
|
||||
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
|
||||
const attributes = parseAttributes(line);
|
||||
const track: SubtitleTrack = {
|
||||
index: index++,
|
||||
name: attributes.NAME || "",
|
||||
uri: attributes.URI || "",
|
||||
language: attributes.LANGUAGE || "",
|
||||
default: attributes.DEFAULT === "YES",
|
||||
forced: attributes.FORCED === "YES",
|
||||
autoSelect: attributes.AUTOSELECT === "YES",
|
||||
};
|
||||
subtitleTracks.push(track);
|
||||
}
|
||||
});
|
||||
|
||||
return subtitleTracks;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch or parse the M3U8 file:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAttributes(line: string): { [key: string]: string } {
|
||||
const attributes: { [key: string]: string } = {};
|
||||
const regex = /([A-Z-]+)=(?:"([^"]*)"|([^,]*))/g;
|
||||
|
||||
for (const match of line.matchAll(regex)) {
|
||||
const key = match[1];
|
||||
const value = match[2] ?? match[3]; // quoted or unquoted
|
||||
attributes[key] = value;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import type { Settings } from "../../atoms/settings";
|
||||
import { generateDeviceProfile } from "../../profiles/native";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface PostCapabilitiesParams {
|
||||
api: Api | null | undefined;
|
||||
itemId: string | null | undefined;
|
||||
sessionId: string | null | undefined;
|
||||
deviceProfile: Settings["deviceProfile"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a media item as not played for a specific user.
|
||||
*
|
||||
* @param params - The parameters for marking an item as not played
|
||||
* @returns A promise that resolves to true if the operation was successful, false otherwise
|
||||
*/
|
||||
export const postCapabilities = async ({
|
||||
api,
|
||||
itemId,
|
||||
sessionId,
|
||||
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
|
||||
if (!api || !itemId || !sessionId) {
|
||||
throw new Error("Missing parameters for marking item as not played");
|
||||
}
|
||||
|
||||
try {
|
||||
const d = api.axiosInstance.post(
|
||||
`${api.basePath}/Sessions/Capabilities/Full`,
|
||||
{
|
||||
playableMediaTypes: ["Audio", "Video"],
|
||||
supportedCommands: [
|
||||
"PlayState",
|
||||
"Play",
|
||||
"ToggleFullscreen",
|
||||
"DisplayMessage",
|
||||
"Mute",
|
||||
"Unmute",
|
||||
"SetVolume",
|
||||
"ToggleMute",
|
||||
],
|
||||
supportsMediaControl: true,
|
||||
id: sessionId,
|
||||
DeviceProfile: generateDeviceProfile(),
|
||||
},
|
||||
{
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
return d;
|
||||
} catch (_error) {
|
||||
throw new Error("Failed to mark as not played");
|
||||
}
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getAuthHeaders } from "../jellyfin";
|
||||
|
||||
interface NextUpParams {
|
||||
itemId?: string | null;
|
||||
userId?: string | null;
|
||||
api?: Api | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the next up episodes for a series or all series for a user.
|
||||
*
|
||||
* @param params - The parameters for fetching next up episodes
|
||||
* @returns A promise that resolves to an array of BaseItemDto representing the next up episodes
|
||||
*/
|
||||
export const nextUp = async ({
|
||||
itemId,
|
||||
userId,
|
||||
api,
|
||||
}: NextUpParams): Promise<BaseItemDto[]> => {
|
||||
if (!userId || !api) {
|
||||
console.error("Invalid parameters for nextUp: missing userId or api");
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.axiosInstance.get<{ Items: BaseItemDto[] }>(
|
||||
`${api.basePath}/Shows/NextUp`,
|
||||
{
|
||||
params: {
|
||||
SeriesId: itemId || undefined,
|
||||
UserId: userId,
|
||||
Fields: "MediaSourceCount",
|
||||
},
|
||||
headers: getAuthHeaders(api),
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
|
||||
/**
|
||||
* Retrieves an item by its ID from the API.
|
||||
*
|
||||
* @param api - The Jellyfin API instance.
|
||||
* @param itemId - The ID of the item to retrieve.
|
||||
* @returns The item object or undefined if no item matches the ID.
|
||||
*/
|
||||
export const getItemById = async (
|
||||
api?: Api | null | undefined,
|
||||
itemId?: string | null | undefined,
|
||||
): Promise<BaseItemDto | undefined> => {
|
||||
if (!api || !itemId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemData = await getUserLibraryApi(api).getItem({ itemId });
|
||||
|
||||
const item = itemData.data;
|
||||
if (!item) {
|
||||
console.error("No items found with the specified ID:", itemId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve the item:", error);
|
||||
throw new Error(`Failed to retrieve the item due to an error: ${error}`);
|
||||
}
|
||||
};
|
||||
@@ -27,7 +27,6 @@ export function startPairingListener(
|
||||
});
|
||||
|
||||
socket.on("error", (err) => {
|
||||
if (!active) return;
|
||||
if (__DEV__) console.error("[PairingService] Socket error:", err);
|
||||
onError?.(err.message);
|
||||
cleanup();
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// seconds to ticks util
|
||||
|
||||
export function secondsToTicks(seconds: number): number {
|
||||
return seconds * 10000000;
|
||||
}
|
||||
Reference in New Issue
Block a user