Compare commits

..

8 Commits

Author SHA1 Message Date
Gauvain
f95faacece Merge branch 'develop' into cleanup/dead-code 2026-06-02 23:36:59 +02:00
Gauvain
77d3bc2563 refactor: remove unused clearAllCredentials and log diagnostics helpers
clearAllCredentials() (secureCredentials.ts), clearLogs() and
dumpDownloadDiagnostics() (log.tsx) have no callers anywhere in the tree.
Carried over from the original #1347 cleanup.
2026-06-02 23:20:17 +02:00
Gauvain
89fb0d9624 chore(seerr): remove early-stage warning notice and stale optimize-server reference
Removes the red 'early stages' notice on the Seer plugin page (and its
jellyseerr_warning key), and drops the deprecated optimize-server mention from
the welcome screen downloads description. (Pre-release cleanup, Chris's list.)
2026-06-02 23:20:10 +02:00
Gauvain
7564463065 Merge branch 'develop' into cleanup/dead-code 2026-06-02 23:01:31 +02:00
Gauvain
f7cd413882 Merge branch 'develop' into cleanup/dead-code 2026-06-02 18:43:22 +02:00
Alex
d7fbe992ae Merge branch 'develop' into cleanup/dead-code 2026-06-02 22:51:20 +10:00
Alex
13973dc53a Merge branch 'develop' into cleanup/dead-code 2026-06-01 19:24:11 +10:00
Gauvain
8eeb571f33 refactor: remove dead code
Delete unused components, hooks and utilities that have no remaining references, and drop their now-unused exports (augmentations/string, Number.prototype.hoursToMilliseconds). Also drop the dangling ExampleGlobalModalUsage reference from GLOBAL_MODAL_GUIDE.md.
2026-06-01 00:54:18 +02:00
38 changed files with 209 additions and 1544 deletions

View File

@@ -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 ## Default Styling
The modal uses these default styles (can be overridden via options): The modal uses these default styles (can be overridden via options):

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Directory, Paths } from "expo-file-system"; import { Directory, Paths } from "expo-file-system";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, View } from "react-native"; import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -21,8 +21,6 @@ import {
TVSettingsToggle, TVSettingsToggle,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrConnect } from "@/hooks/useJellyseerrConnect";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal"; import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
import { APP_LANGUAGES } from "@/i18n"; import { APP_LANGUAGES } from "@/i18n";
@@ -61,37 +59,6 @@ export default function SettingsTV() {
const { showUserSwitchModal } = useTVUserSwitchModal(); const { showUserSwitchModal } = useTVUserSwitchModal();
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { jellyseerrApi, clearAllJellyseerData } = useJellyseerr();
const { connecting: jellyseerrConnecting, connect: jellyseerrConnect } =
useJellyseerrConnect();
// Jellyseerr state
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
settings.jellyseerrServerUrl || "",
);
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
const { pluginSettings } = useSettings();
const isJellyseerrLocked =
pluginSettings?.jellyseerrServerUrl?.locked === true;
const isJellyseerrConnected = !!jellyseerrApi;
const handleJellyseerrUrlBlur = useCallback(() => {
const url = jellyseerrServerUrl.trim();
updateSettings({ jellyseerrServerUrl: url || undefined });
}, [jellyseerrServerUrl, updateSettings]);
const handleJellyseerrConnect = useCallback(async () => {
const url = jellyseerrServerUrl.trim();
if (!url) return;
await jellyseerrConnect(url, jellyseerrPassword);
}, [jellyseerrServerUrl, jellyseerrPassword, jellyseerrConnect]);
const handleDisconnectJellyseerr = useCallback(() => {
clearAllJellyseerData();
setJellyseerrServerUrl("");
setJellyseerrPassword("");
}, [clearAllJellyseerData]);
// Local state for OpenSubtitles API key (only commit on blur) // Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -916,81 +883,6 @@ export default function SettingsTV() {
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })} onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
/> />
{/* seerr Section */}
<TVSectionHeader title='seerr' />
<Text
style={{
color: "#9CA3AF",
fontSize: typography.callout - 2,
marginBottom: 16,
marginLeft: 8,
}}
>
{t("home.settings.plugins.jellyseerr.server_url_hint") ||
"Enter your Jellyseerr server URL to enable discover and request features."}
</Text>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
}
value={jellyseerrServerUrl}
placeholder={
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
"https://jellyseerr.example.com"
}
onChangeText={setJellyseerrServerUrl}
onBlur={handleJellyseerrUrlBlur}
disabled={isJellyseerrLocked || jellyseerrConnecting}
/>
{!isJellyseerrConnected && !isJellyseerrLocked && (
<>
<TVSettingsTextInput
label={
t("home.settings.plugins.jellyseerr.password") || "Password"
}
value={jellyseerrPassword}
placeholder={
t("home.settings.plugins.jellyseerr.password_placeholder", {
username: user?.Name,
}) || `Jellyfin password`
}
onChangeText={setJellyseerrPassword}
secureTextEntry
disabled={jellyseerrConnecting}
/>
<TVSettingsOptionButton
label={
jellyseerrConnecting
? t("common.connecting", "Connecting...") || "Connecting..."
: t("common.connect", "Connect") || "Connect"
}
value=''
onPress={handleJellyseerrConnect}
disabled={jellyseerrConnecting}
/>
</>
)}
<TVSettingsRow
label={
isJellyseerrConnected
? t("common.connected", "Connected") || "Connected"
: t("common.not_connected", "Not connected") || "Not connected"
}
value=''
showChevron={false}
/>
{isJellyseerrConnected && !isJellyseerrLocked && (
<TVSettingsOptionButton
label={
t(
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
) || "Disconnect"
}
value=''
onPress={handleDisconnectJellyseerr}
/>
)}
{/* Storage Section */} {/* Storage Section */}
<TVSectionHeader title={t("home.settings.storage.storage_title")} /> <TVSectionHeader title={t("home.settings.storage.storage_title")} />
<TVSettingsOptionButton <TVSettingsOptionButton

View File

@@ -1,4 +1,3 @@
export * from "./api"; export * from "./api";
export * from "./mmkv"; export * from "./mmkv";
export * from "./number"; export * from "./number";
export * from "./string";

View File

@@ -3,7 +3,6 @@ declare global {
bytesToReadable(decimals?: number): string; bytesToReadable(decimals?: number): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number;
} }
} }
@@ -28,8 +27,4 @@ Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds(); return this.valueOf() * (60).secondsToMilliseconds();
}; };
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds();
};
export {}; export {};

View File

@@ -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 {};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { import {
@@ -23,6 +22,8 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVDiscoverPosterProps { interface TVDiscoverPosterProps {
item: MovieResult | TvResult; item: MovieResult | TvResult;
isFirstItem?: boolean; isFirstItem?: boolean;
@@ -33,7 +34,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
isFirstItem = false, isFirstItem = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const router = useRouter(); const router = useRouter();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
@@ -50,8 +50,6 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
const handlePress = () => { const handlePress = () => {
router.push({ router.push({
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page", pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
@@ -73,7 +71,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: posterWidth, width: 210,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -83,9 +81,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
<View <View
style={{ style={{
width: posterWidth, width: 210,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: sizes.gaps.small, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -142,12 +140,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
> >
{title} {title}
</Text> </Text>
{year != null && ( {year && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
color: "#9CA3AF", color: "#9CA3AF",
marginTop: sizes.gaps.small, marginTop: 2,
}} }}
> >
{year} {year}
@@ -168,7 +166,6 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
isFirstSlide = false, isFirstSlide = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { t } = useTranslation(); const { t } = useTranslation();
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr(); const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
@@ -234,14 +231,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
if (!flatData || flatData.length === 0) return null; if (!flatData || flatData.length === 0) return null;
return ( return (
<View style={{ marginBottom: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{slideTitle} {slideTitle}
@@ -252,9 +249,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
onEndReached={() => { onEndReached={() => {

View File

@@ -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} />;
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { Animated, FlatList, Pressable, View } from "react-native"; import { Animated, FlatList, Pressable, View } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation"; import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
import { useScaledTVSizes } from "@/constants/TVSizes";
import { useScaledTVTypography } from "@/constants/TVTypography"; import { useScaledTVTypography } from "@/constants/TVTypography";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media"; import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
@@ -15,6 +14,8 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
const SCALE_PADDING = 20;
interface TVJellyseerrPosterProps { interface TVJellyseerrPosterProps {
item: MovieResult | TvResult; item: MovieResult | TvResult;
onPress: () => void; onPress: () => void;
@@ -27,7 +28,6 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
isFirstItem = false, isFirstItem = false,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi, getTitle, getYear } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 }); useTVFocusAnimation({ scaleAmount: 1.05 });
@@ -43,8 +43,6 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
item.mediaInfo?.status === MediaStatus.AVAILABLE || item.mediaInfo?.status === MediaStatus.AVAILABLE ||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE; item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
const posterWidth = sizes.posters.poster;
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
@@ -56,7 +54,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: posterWidth, width: 210,
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0, shadowOpacity: focused ? 0.6 : 0,
@@ -66,9 +64,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
> >
<View <View
style={{ style={{
width: posterWidth, width: 210,
aspectRatio: 10 / 15, aspectRatio: 10 / 15,
borderRadius: sizes.gaps.small, borderRadius: 24,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
}} }}
@@ -119,13 +117,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: "#fff", color: "#fff",
fontWeight: "600", fontWeight: "600",
marginTop: sizes.gaps.small, marginTop: 12,
}} }}
numberOfLines={2} numberOfLines={2}
> >
{title} {title}
</Text> </Text>
{year != null && ( {year && (
<Text <Text
style={{ style={{
fontSize: typography.callout, fontSize: typography.callout,
@@ -151,7 +149,6 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
onPress, onPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { focused, handleFocus, handleBlur, animatedStyle } = const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation(); useTVFocusAnimation();
@@ -160,15 +157,13 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
? jellyseerrApi?.imageProxy(item.profilePath, "w185") ? jellyseerrApi?.imageProxy(item.profilePath, "w185")
: null; : null;
const avatarSize = Math.round(sizes.posters.poster * 0.67);
return ( return (
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}> <Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
<Animated.View <Animated.View
style={[ style={[
animatedStyle, animatedStyle,
{ {
width: avatarSize, width: 160,
alignItems: "center", alignItems: "center",
shadowColor: "#fff", shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
@@ -179,9 +174,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
> >
<View <View
style={{ style={{
width: avatarSize, width: 140,
height: avatarSize, height: 140,
borderRadius: avatarSize / 2, borderRadius: 70,
overflow: "hidden", overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)", backgroundColor: "rgba(255,255,255,0.1)",
borderWidth: focused ? 3 : 0, borderWidth: focused ? 3 : 0,
@@ -203,11 +198,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
alignItems: "center", alignItems: "center",
}} }}
> >
<Ionicons <Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
name='person'
size={Math.round(avatarSize * 0.35)}
color='rgba(255,255,255,0.4)'
/>
</View> </View>
)} )}
</View> </View>
@@ -216,7 +207,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
fontSize: typography.callout, fontSize: typography.callout,
color: focused ? "#fff" : "rgba(255,255,255,0.9)", color: focused ? "#fff" : "rgba(255,255,255,0.9)",
fontWeight: "600", fontWeight: "600",
marginTop: sizes.gaps.small, marginTop: 12,
textAlign: "center", textAlign: "center",
}} }}
numberOfLines={2} numberOfLines={2}
@@ -242,18 +233,17 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{title} {title}
@@ -264,9 +254,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -295,18 +285,17 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{title} {title}
@@ -317,9 +306,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
@@ -348,18 +337,17 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
onItemPress, onItemPress,
}) => { }) => {
const typography = useScaledTVTypography(); const typography = useScaledTVTypography();
const sizes = useScaledTVSizes();
if (!items || items.length === 0) return null; if (!items || items.length === 0) return null;
return ( return (
<View style={{ marginBottom: sizes.gaps.section }}> <View style={{ marginBottom: 24 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: typography.heading,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: sizes.gaps.small, marginBottom: 16,
marginLeft: sizes.padding.scale, marginLeft: SCALE_PADDING,
}} }}
> >
{title} {title}
@@ -370,9 +358,9 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: sizes.padding.scale, paddingHorizontal: SCALE_PADDING,
paddingVertical: sizes.padding.scale, paddingVertical: SCALE_PADDING,
gap: sizes.gaps.item, gap: 20,
}} }}
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
renderItem={({ item }) => ( renderItem={({ item }) => (
@@ -412,7 +400,6 @@ export const TVJellyseerrSearchResults: React.FC<
onPersonPress, onPersonPress,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const typography = useScaledTVTypography();
if (loading) { if (loading) {
return null; return null;
@@ -423,7 +410,7 @@ export const TVJellyseerrSearchResults: React.FC<
<View style={{ alignItems: "center", paddingTop: 40 }}> <View style={{ alignItems: "center", paddingTop: 40 }}>
<Text <Text
style={{ style={{
fontSize: typography.heading, fontSize: 24,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 8, marginBottom: 8,
@@ -431,9 +418,7 @@ export const TVJellyseerrSearchResults: React.FC<
> >
{t("search.no_results_found_for")} {t("search.no_results_found_for")}
</Text> </Text>
<Text <Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
>
"{searchQuery}" "{searchQuery}"
</Text> </Text>
</View> </View>

View File

@@ -1,8 +1,8 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TextInput, View } from "react-native"; import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover"; import { TVDiscover } from "@/components/jellyseerr/discover/TVDiscover";
@@ -166,7 +166,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [isSearchFocused, setIsSearchFocused] = useState(false);
// Image URL getter for music items // Image URL getter for music items
const getImageUrl = useMemo(() => { const getImageUrl = useMemo(() => {
@@ -232,51 +231,26 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
paddingTop: insets.top + TOP_PADDING, paddingTop: insets.top + TOP_PADDING,
}} }}
> >
{/* Search bar: native tvOS SwiftUI `.searchable` on Apple TV, standard {/* Native tvOS search field (SwiftUI `.searchable`, our `tv-search`
TextInput fallback on Android TV (the native module is Apple-only). */} module). It renders the native search bar + grid keyboard and
{Platform.OS === "ios" ? ( forwards typed text into the existing query pipeline via setSearch;
<View our own results grid renders below. */}
style={{ {/* No horizontal margin here: the native tvOS search bar centers itself
marginBottom: 24, and renders a trailing "Hold to Dictate in <Language>" hint. Extra
height: SEARCH_AREA_HEIGHT, 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
{/* No horizontal margin here: the native tvOS search bar centers style={{
itself and renders a trailing "Hold to Dictate" hint. */} marginBottom: 24,
<TvSearchView height: SEARCH_AREA_HEIGHT,
style={{ width: "100%", height: "100%" }} }}
placeholder={t("search.search")} >
onChangeText={(e) => setSearch(e.nativeEvent.text)} <TvSearchView
/> style={{ width: "100%", height: "100%" }}
</View> placeholder={t("search.search")}
) : ( onChangeText={(e) => setSearch(e.nativeEvent.text)}
<View />
style={{ </View>
marginHorizontal: HORIZONTAL_PADDING,
marginBottom: 24,
}}
>
<TextInput
style={{
height: 56,
width: "100%",
backgroundColor: "#262626",
borderRadius: 12,
paddingHorizontal: 20,
fontSize: 28,
color: "#fff",
}}
placeholder={t("search.search")}
placeholderTextColor='rgba(255,255,255,0.4)'
onChangeText={setSearch}
defaultValue=''
autoFocus={false}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
hasTVPreferredFocus
/>
</View>
)}
</View> </View>
<ScrollView <ScrollView
@@ -294,7 +268,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
searchType={searchType} searchType={searchType}
setSearchType={setSearchType} setSearchType={setSearchType}
showDiscover={showDiscover} showDiscover={showDiscover}
disabled={isSearchFocused}
/> />
</View> </View>
)} )}
@@ -321,7 +294,6 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
// every keystroke as results re-render. User navigates down to the // every keystroke as results re-render. User navigates down to the
// grid manually. // grid manually.
isFirstSection={false} isFirstSection={false}
disabled={isSearchFocused}
onItemPress={onItemPress} onItemPress={onItemPress}
onItemLongPress={onItemLongPress} onItemLongPress={onItemLongPress}
imageUrlGetter={ imageUrlGetter={

View File

@@ -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>
);
};

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -1,3 +0,0 @@
export default function DownloadSettings() {
return null;
}

View File

@@ -115,9 +115,6 @@ export const JellyseerrSettings = () => {
</> </>
) : ( ) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'> <View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.jellyseerr.jellyseerr_warning")}
</Text>
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")} {t("home.settings.plugins.jellyseerr.server_url")}
</Text> </Text>

View File

@@ -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" },
];

View File

@@ -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 };
};

View File

@@ -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 };
};

View File

@@ -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;
};

View File

@@ -213,7 +213,7 @@ public class MpvPlayerModule: Module {
} }
// Defines events that the view can send to JavaScript // Defines events that the view can send to JavaScript
Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady", "onPictureInPictureChange") Events("onLoad", "onPlaybackStateChange", "onProgress", "onError", "onTracksReady")
} }
} }
} }

View File

@@ -61,7 +61,6 @@ class MpvPlayerView: ExpoView {
let onProgress = EventDispatcher() let onProgress = EventDispatcher()
let onError = EventDispatcher() let onError = EventDispatcher()
let onTracksReady = EventDispatcher() let onTracksReady = EventDispatcher()
let onPictureInPictureChange = EventDispatcher()
private var currentURL: URL? private var currentURL: URL?
private var cachedPosition: Double = 0 private var cachedPosition: Double = 0
@@ -638,9 +637,6 @@ extension MpvPlayerView: PiPControllerDelegate {
print("PiP did start: \(didStartPictureInPicture)") print("PiP did start: \(didStartPictureInPicture)")
// Ensure current time is synced when PiP starts // Ensure current time is synced when PiP starts
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration) pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
// Notify JS of the actual PiP active state. `didStartPictureInPicture`
// is `false` when AVKit reports a failure to start, so reflect that.
onPictureInPictureChange(["isActive": didStartPictureInPicture])
} }
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) { func pipController(_ controller: PiPController, willStopPictureInPicture: Bool) {
@@ -659,9 +655,6 @@ extension MpvPlayerView: PiPControllerDelegate {
if _isZoomedToFill { if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill displayLayer.videoGravity = .resizeAspectFill
} }
// Notify JS that PiP has fully stopped so the controls overlay can
// be re-mounted when the user returns to full screen.
onPictureInPictureChange(["isActive": false])
} }
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) { func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {

View File

@@ -1,13 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<application> <application>
<receiver android:name=".TvRecommendationsReceiver" android:exported="true"> <receiver
android:name=".TvRecommendationsReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" /> <action android:name="android.media.tv.action.INITIALIZE_PROGRAMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.tv.action.PREVIEW_PROGRAM_BROWSABLE_DISABLED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>

View File

@@ -61,61 +61,31 @@ internal object TvRecommendationsPublisher {
fun clear(context: Context): Boolean { fun clear(context: Context): Boolean {
val prefs = preferences(context) val prefs = preferences(context)
val channelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val programIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
// KEY_PROGRAM_IDS is now { channelId: "{ providerId: programId }" } if (programIds != null) {
val allProgramIds = prefs.getString(KEY_PROGRAM_IDS, null)?.let(::JSONObject)
if (allProgramIds != null) {
var deletedPrograms = 0 var deletedPrograms = 0
val channelKeys = allProgramIds.keys() val keys = programIds.keys()
while (channelKeys.hasNext()) { while (keys.hasNext()) {
val channelIdStr = channelKeys.next() val key = keys.next()
val programIdsJson = allProgramIds.optString(channelIdStr) val programId = programIds.optLong(key, -1L)
if (programIdsJson.isBlank()) continue if (programId > 0L) {
contentResolver.delete(
try { TvContractCompat.buildPreviewProgramUri(programId),
val programIds = JSONObject(programIdsJson) null,
val keys = programIds.keys() null
while (keys.hasNext()) { )
val providerId = keys.next() deletedPrograms += 1
val programId = programIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
deletedPrograms += 1
}
}
} catch (e: Exception) {
Log.w(TAG, "clear(): failed to parse programIds for channel $channelIdStr", e)
} }
// Notify the channel
val channelId = channelIdStr.toLongOrNull() ?: -1L
if (channelId > 0L) {
try {
contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
} catch (e: SecurityException) {
Log.w(TAG, "clear(): lost provider permission, cannot notify channel $channelId", e)
}
}
// Remove per-channel pref
prefs.edit().remove("programIds_$channelIdStr").apply()
} }
Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)") Log.d(TAG, "clear(): deleted $deletedPrograms preview program(s)")
} }
// Also handle legacy format (flat { providerId: programId }) for migration if (channelId > 0L) {
val legacyProgramIds = prefs.getString("legacy_" + KEY_PROGRAM_IDS, null)?.let(::JSONObject) contentResolver.notifyChange(TvContractCompat.buildChannelUri(channelId), null)
if (legacyProgramIds != null) { Log.d(TAG, "clear(): notified channel $channelId")
val keys = legacyProgramIds.keys()
while (keys.hasNext()) {
val key = keys.next()
val programId = legacyProgramIds.optLong(key, -1L)
if (programId > 0L) {
deletePreviewProgram(contentResolver, programId)
}
}
prefs.edit().remove("legacy_" + KEY_PROGRAM_IDS).apply()
} }
prefs.edit() prefs.edit()
@@ -126,262 +96,126 @@ internal object TvRecommendationsPublisher {
return true return true
} }
/**
* Delete a single preview program from the TvProvider.
* Called by clear(), synchronize() (stale programs), and TvRecommendationsReceiver (user removed).
*/
fun deletePreviewProgram(context: Context, programId: Long) {
try {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
Log.d(TAG, "deletePreviewProgram(): deleted programId=$programId")
// Also remove from stored programIds prefs
removeProgramFromPrefs(context, programId)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun deletePreviewProgram(contentResolver: android.content.ContentResolver, programId: Long) {
try {
contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
} catch (e: SecurityException) {
Log.w(TAG, "deletePreviewProgram(): lost provider permission for programId=$programId", e)
}
}
private fun removeProgramFromPrefs(context: Context, programId: Long) {
val prefs = preferences(context)
val programIdsJson = prefs.getString(KEY_PROGRAM_IDS, null) ?: return
try {
val programIds = JSONObject(programIdsJson)
val keys = programIds.keys()
while (keys.hasNext()) {
val key = keys.next()
if (programIds.optLong(key, -1L) == programId) {
programIds.remove(key)
break
}
}
prefs.edit().putString(KEY_PROGRAM_IDS, programIds.toString()).apply()
} catch (e: Exception) {
Log.w(TAG, "removeProgramFromPrefs(): failed to update prefs for programId=$programId", e)
}
}
private fun synchronize(context: Context, payload: JSONObject): Boolean { private fun synchronize(context: Context, payload: JSONObject): Boolean {
val sections = payload.optJSONArray("sections") ?: JSONArray() val sections = payload.optJSONArray("sections") ?: JSONArray()
if (sections.length() == 0) { val firstSection = if (sections.length() > 0) sections.optJSONObject(0) else null
Log.w(TAG, "synchronize(): no sections in payload") val sectionTitle = firstSection?.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
return false val items = firstSection?.optJSONArray("items") ?: JSONArray()
}
val prefs = preferences(context)
val allNextProgramIds = JSONObject()
var totalActive = 0
var totalDeleted = 0
for (sectionIndex in 0 until sections.length()) {
val section = sections.optJSONObject(sectionIndex) ?: continue
val sectionTitle = section.optString("title")?.takeIf { it.isNotBlank() } ?: DEFAULT_CHANNEL_NAME
val items = section.optJSONArray("items") ?: JSONArray()
Log.d(
TAG,
"synchronize(): section \"$sectionTitle\" ($sectionIndex/${sections.length()}) with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create channel for \"$sectionTitle\"")
continue
}
// Per Android docs: check channel.isBrowsable() and request if needed.
if (!isChannelBrowsable(context, channelId)) {
Log.d(TAG, "synchronize(): channel $channelId not browsable, requesting browsable")
TvContractCompat.requestChannelBrowsable(context, channelId)
}
val prefKey = "programIds_$channelId"
val previousProgramIds = prefs.getString(prefKey, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
deletePreviewProgram(context, programId)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
allNextProgramIds.put(channelId.toString(), nextProgramIds.toString())
prefs.edit().putString(prefKey, nextProgramIds.toString()).apply()
totalActive += activeProviderIds.size
totalDeleted += deletedPrograms
logProviderState(context, channelId)
}
// Store all channel program IDs for clear() to use
prefs.edit().putString(KEY_PROGRAM_IDS, allNextProgramIds.toString()).apply()
Log.d( Log.d(
TAG, TAG,
"synchronize(): completed across ${sections.length()} section(s), $totalActive active program(s), deleted $totalDeleted stale program(s)" "synchronize(): using section \"$sectionTitle\" with ${items.length()} item(s)"
)
val channelId = getOrCreateChannel(context, sectionTitle)
if (channelId <= 0L) {
Log.w(TAG, "synchronize(): failed to get or create preview channel")
return false
}
Log.d(TAG, "synchronize(): publishing into channelId=$channelId")
val previousProgramIds = preferences(context)
.getString(KEY_PROGRAM_IDS, null)
?.let(::JSONObject)
?: JSONObject()
val nextProgramIds = JSONObject()
val activeProviderIds = mutableSetOf<String>()
for (index in 0 until items.length()) {
val item = items.optJSONObject(index) ?: continue
val providerId = item.optString("id")
if (providerId.isBlank()) continue
val programId = upsertPreviewProgram(
context = context,
channelId = channelId,
item = item,
previousProgramId = previousProgramIds.optLong(providerId, -1L),
weight = index
)
if (programId > 0L) {
activeProviderIds += providerId
nextProgramIds.put(providerId, programId)
Log.d(TAG, "synchronize(): upserted program for item=$providerId programId=$programId")
}
}
var deletedPrograms = 0
val previousKeys = previousProgramIds.keys()
while (previousKeys.hasNext()) {
val providerId = previousKeys.next()
if (activeProviderIds.contains(providerId)) continue
val programId = previousProgramIds.optLong(providerId, -1L)
if (programId > 0L) {
context.contentResolver.delete(
TvContractCompat.buildPreviewProgramUri(programId),
null,
null
)
deletedPrograms += 1
Log.d(TAG, "synchronize(): deleted stale programId=$programId for item=$providerId")
}
}
preferences(context)
.edit()
.putLong(KEY_CHANNEL_ID, channelId)
.putString(KEY_PROGRAM_IDS, nextProgramIds.toString())
.apply()
logProviderState(context, channelId)
Log.d(
TAG,
"synchronize(): completed with ${activeProviderIds.size} active program(s), deleted $deletedPrograms stale program(s)"
) )
return true return true
} }
/**
* Query provider to check if a channel is browsable.
* Per Android docs: "check channel.isBrowsable() before updating programs."
*/
private fun isChannelBrowsable(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val browsableIndex = cursor.getColumnIndex(TvContractCompat.Channels.COLUMN_BROWSABLE)
if (browsableIndex >= 0) cursor.getInt(browsableIndex) == 1 else true
} else {
false
}
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "isChannelBrowsable(): lost provider permission for channelId=$channelId", e)
true // Assume browsable if we can't check, to avoid blocking updates
}
}
/**
* Query provider to verify a channel actually exists.
* Prevents the channel-delete-recreate bug: if update() returns 0 rows
* we must first check whether the channel was deleted by the system
* or if the update simply failed for another reason.
*/
private fun channelExistsInProvider(context: Context, channelId: Long): Boolean {
return try {
context.contentResolver.query(
TvContractCompat.buildChannelUri(channelId),
null,
null,
null,
null
)?.use { cursor ->
cursor.moveToFirst()
} ?: false
} catch (e: SecurityException) {
Log.w(TAG, "channelExistsInProvider(): lost provider permission for channelId=$channelId", e)
false
}
}
private fun getOrCreateChannel(context: Context, displayName: String): Long { private fun getOrCreateChannel(context: Context, displayName: String): Long {
val prefs = preferences(context) val prefs = preferences(context)
val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L) val existingChannelId = prefs.getLong(KEY_CHANNEL_ID, -1L)
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (existingChannelId > 0L) { if (existingChannelId > 0L) {
// Query provider first to verify channel actually exists (prevents recreate bug) val updated = Channel.Builder()
val exists = channelExistsInProvider(context, existingChannelId) .setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build()
if (exists) { val updatedRows = contentResolver.update(
// Channel exists — update it in place, never recreate TvContractCompat.buildChannelUri(existingChannelId),
val updated = Channel.Builder() updated.toContentValues(),
.setType(TvContractCompat.Channels.TYPE_PREVIEW) null,
.setDisplayName(displayName) null
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) )
.build()
try { if (updatedRows > 0) {
val updatedRows = contentResolver.update( TvContractCompat.requestChannelBrowsable(context, existingChannelId)
TvContractCompat.buildChannelUri(existingChannelId), storeChannelLogo(context, existingChannelId)
updated.toContentValues(), Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
null, return existingChannelId
null
)
if (updatedRows > 0) {
TvContractCompat.requestChannelBrowsable(context, existingChannelId)
storeChannelLogo(context, existingChannelId)
Log.d(TAG, "getOrCreateChannel(): updated existing channelId=$existingChannelId and requested browsable")
return existingChannelId
}
// Update returned 0 rows but channel exists — log and return existing ID, don't recreate
Log.e(TAG, "getOrCreateChannel(): update returned 0 for existing channelId=$existingChannelId but channel exists — not recreating")
return existingChannelId
} catch (e: SecurityException) {
Log.w(TAG, "getOrCreateChannel(): lost provider permission updating channelId=$existingChannelId", e)
return existingChannelId
}
} }
// Channel truly doesn't exist in provider — recreate Log.w(TAG, "getOrCreateChannel(): stored channelId=$existingChannelId was stale, recreating")
Log.w(TAG, "getOrCreateChannel(): channelId=$existingChannelId not in provider, recreating")
prefs.edit().remove(KEY_CHANNEL_ID).apply() prefs.edit().remove(KEY_CHANNEL_ID).apply()
} }
// Create a new channel
val channel = Channel.Builder() val channel = Channel.Builder()
.setType(TvContractCompat.Channels.TYPE_PREVIEW) .setType(TvContractCompat.Channels.TYPE_PREVIEW)
.setDisplayName(displayName) .setDisplayName(displayName)
.setAppLinkIntentUri(buildIntentUri(context, "streamyfin://")) .setAppLinkIntentUri(buildIntentUri(context, "streamyfin://"))
.build() .build()
val channelUri = try { val channelUri = contentResolver.insert(
contentResolver.insert( TvContractCompat.Channels.CONTENT_URI,
TvContractCompat.Channels.CONTENT_URI, channel.toContentValues()
channel.toContentValues() ) ?: return -1L
)
} catch (e: SecurityException) {
Log.e(TAG, "getOrCreateChannel(): lost provider permission, cannot create channel", e)
null
} ?: return -1L
val channelId = ContentUris.parseId(channelUri) val channelId = ContentUris.parseId(channelUri)
TvContractCompat.requestChannelBrowsable(context, channelId) TvContractCompat.requestChannelBrowsable(context, channelId)
@@ -415,62 +249,42 @@ internal object TvRecommendationsPublisher {
builder.setDescription(it) builder.setDescription(it)
} }
// Per Android docs: use unique URIs for all images to avoid stale cache
imageUrl.takeIf { it.isNotBlank() }?.let { imageUrl.takeIf { it.isNotBlank() }?.let {
val uniqueImageUrl = appendCacheBuster(it) val imageUri = Uri.parse(it)
val imageUri = Uri.parse(uniqueImageUrl)
builder.setPosterArtUri(imageUri) builder.setPosterArtUri(imageUri)
builder.setThumbnailUri(imageUri) builder.setThumbnailUri(imageUri)
} }
val contentValues = builder.build().toContentValues() val contentValues = builder.build().toContentValues()
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
if (previousProgramId > 0L) { if (previousProgramId > 0L) {
try { val updatedRows = contentResolver.update(
val updatedRows = contentResolver.update( TvContractCompat.buildPreviewProgramUri(previousProgramId),
TvContractCompat.buildPreviewProgramUri(previousProgramId), contentValues,
contentValues, null,
null, null
null )
)
if (updatedRows > 0) { if (updatedRows > 0) {
Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId") Log.d(TAG, "upsertPreviewProgram(): updated existing programId=$previousProgramId")
return previousProgramId return previousProgramId
}
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission for programId=$previousProgramId", e)
} }
Log.w(TAG, "upsertPreviewProgram(): existing programId=$previousProgramId was stale, inserting new row")
} }
val insertedUri = try { val insertedUri = contentResolver.insert(
contentResolver.insert( TvContractCompat.PreviewPrograms.CONTENT_URI,
TvContractCompat.PreviewPrograms.CONTENT_URI, contentValues
contentValues ) ?: return -1L
)
} catch (e: SecurityException) {
Log.w(TAG, "upsertPreviewProgram(): lost provider permission, cannot insert program", e)
null
} ?: return -1L
val programId = ContentUris.parseId(insertedUri) val programId = ContentUris.parseId(insertedUri)
Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId") Log.d(TAG, "upsertPreviewProgram(): inserted new programId=$programId")
return programId return programId
} }
/**
* Append a cache-busting parameter to ensure unique URIs when images change.
* Per Android docs: "Use unique Uris for all images... the old image will
* continue to appear if you don't change the Uri."
*/
private fun appendCacheBuster(imageUrl: String): String {
val separator = if (imageUrl.contains("?")) "&" else "?"
return "$imageUrl${separator}_t=${System.currentTimeMillis()}"
}
private fun buildIntentUri(context: Context, deepLink: String): Uri { private fun buildIntentUri(context: Context, deepLink: String): Uri {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(deepLink) data = Uri.parse(deepLink)
@@ -492,17 +306,13 @@ internal object TvRecommendationsPublisher {
private fun storeChannelLogo(context: Context, channelId: Long) { private fun storeChannelLogo(context: Context, channelId: Long) {
val bitmap = applicationIconBitmap(context) ?: return val bitmap = applicationIconBitmap(context) ?: return
try { val outputStream = context.contentResolver.openOutputStream(
val outputStream = context.contentResolver.openOutputStream( TvContractCompat.buildChannelLogoUri(channelId)
TvContractCompat.buildChannelLogoUri(channelId) ) ?: return
) ?: return
outputStream.use { stream -> outputStream.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
stream.flush() stream.flush()
}
} catch (e: SecurityException) {
Log.w(TAG, "storeChannelLogo(): lost provider permission for channelId=$channelId", e)
} }
} }
@@ -531,14 +341,9 @@ internal object TvRecommendationsPublisher {
return bitmap return bitmap
} }
fun getChannelId(context: Context): Long {
return preferences(context).getLong(KEY_CHANNEL_ID, -1L)
}
private fun preferences(context: Context): SharedPreferences { private fun preferences(context: Context): SharedPreferences {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
} }
private fun logProviderState(context: Context, channelId: Long) { private fun logProviderState(context: Context, channelId: Long) {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
@@ -567,8 +372,8 @@ internal object TvRecommendationsPublisher {
Log.w(TAG, "logProviderState(): channelId=$channelId exists=false") Log.w(TAG, "logProviderState(): channelId=$channelId exists=false")
} }
} ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId") } ?: Log.w(TAG, "logProviderState(): channel query returned null for channelId=$channelId")
} catch (error: SecurityException) { } catch (error: Exception) {
Log.w(TAG, "logProviderState(): lost provider permission for channelId=$channelId", error) Log.w(TAG, "logProviderState(): failed to query channelId=$channelId", error)
} }
} }
} }

View File

@@ -1,19 +1,12 @@
import { requireNativeView } from "expo"; import { requireNativeView } from "expo";
import * as React from "react"; import * as React from "react";
import type { View } from "react-native"; import type { View } from "react-native";
import { Platform } from "react-native";
import type { TvSearchViewProps } from "./TvSearchView.types"; import type { TvSearchViewProps } from "./TvSearchView.types";
// The native TvSearchModule is Apple-only (tvOS SwiftUI `.searchable`).
// On Android the component is never rendered, but we must avoid calling
// `requireNativeView` at module-scope because it would crash on import.
const NativeView: React.ComponentType< const NativeView: React.ComponentType<
TvSearchViewProps & React.RefAttributes<View> TvSearchViewProps & React.RefAttributes<View>
> = > = requireNativeView("TvSearchModule");
Platform.OS === "ios"
? requireNativeView("TvSearchModule")
: ((() => null) as any);
/** /**
* Forwards its ref to the underlying native view so it can be used as a * Forwards its ref to the underlying native view so it can be used as a

View File

@@ -108,7 +108,7 @@
"features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:", "features_description": "Streamyfin has a bunch of features and integrates with a wide array of software which you can find in the settings menu, these include:",
"jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.", "jellyseerr_feature_description": "Connect to your Seerr instance and request movies directly in the app.",
"downloads_feature_title": "Downloads", "downloads_feature_title": "Downloads",
"downloads_feature_description": "Download movies and tv-shows to view offline. Use either the default method or install the optimize server to download files in the background.", "downloads_feature_description": "Download movies and tv-shows to view offline.",
"chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.", "chromecast_feature_description": "Cast movies and tv-shows to your Chromecast devices.",
"centralised_settings_plugin_title": "Centralised Settings Plugin", "centralised_settings_plugin_title": "Centralised Settings Plugin",
"centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.", "centralised_settings_plugin_description": "Configure settings from a centralised location on your Jellyfin server. All client settings for all users will be synced automatically.",
@@ -384,7 +384,6 @@
"plugins": { "plugins": {
"plugins_title": "Plugins", "plugins_title": "Plugins",
"jellyseerr": { "jellyseerr": {
"jellyseerr_warning": "This integration is in its early stages. Expect things to change.",
"server_url": "Server URL", "server_url": "Server URL",
"server_url_hint": "Example: http(s)://your-host.url\n(add port if required)", "server_url_hint": "Example: http(s)://your-host.url\n(add port if required)",
"server_url_placeholder": "Seerr URL", "server_url_placeholder": "Seerr URL",
@@ -592,8 +591,7 @@
"continue": "Continue", "continue": "Continue",
"verifying": "Verifying...", "verifying": "Verifying...",
"login": "Login", "login": "Login",
"refresh": "Refresh", "refresh": "Refresh"
"loading": "Loading..."
}, },
"search": { "search": {
"search": "Search...", "search": "Search...",

View File

@@ -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`;
}

View File

@@ -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;
};

View File

@@ -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;
}

View File

@@ -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");
}
};

View File

@@ -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 [];
}
};

View File

@@ -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}`);
}
};

View File

@@ -72,21 +72,6 @@ export const readFromLog = (): LogEntry[] => {
return logs ? JSON.parse(logs) : []; return logs ? JSON.parse(logs) : [];
}; };
export const clearLogs = () => {
storage.remove("logs");
};
export const dumpDownloadDiagnostics = (extra: any = {}) => {
const diagnostics = {
timestamp: new Date().toISOString(),
processes: extra?.processes || [],
nativeTasks: extra?.nativeTasks || [],
focusedProcess: extra?.focusedProcess || null,
};
writeDebugLog("Download diagnostics", diagnostics);
return diagnostics;
};
export function useLog() { export function useLog() {
const context = useContext(LogContext); const context = useContext(LogContext);
if (context === null) { if (context === null) {

View File

@@ -1,5 +0,0 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

View File

@@ -203,27 +203,6 @@ export async function hasAccountCredential(
return stored !== null; return stored !== null;
} }
/**
* Delete all credentials for all accounts on all servers.
*/
export async function clearAllCredentials(): Promise<void> {
const previousServers = getPreviousServers();
for (const server of previousServers) {
for (const account of server.accounts) {
const key = credentialKey(server.address, account.userId);
await SecureStore.deleteItemAsync(key);
}
}
// Clear all accounts from servers
const clearedServers = previousServers.map((server) => ({
...server,
accounts: [],
}));
storage.set("previousServers", JSON.stringify(clearedServers));
}
/** /**
* Add or update an account in a server's accounts list. * Add or update an account in a server's accounts list.
*/ */