Compare commits

..

3 Commits

Author SHA1 Message Date
Uruk
2c0ed076d5 fix(security): prevent log injection in WebSocket message logging
Sanitize WebSocket messages before logging to prevent log injection attacks.
User-controlled data from WebSocket messages could contain newline characters
that allow forging fake log entries.

Changes:
- Convert message object to JSON string and remove newlines/carriage returns
- Use format specifier (%s) for safe string interpolation
- Applied fix to providers/WebSocketProvider.tsx and hooks/useWebsockets.ts

Resolves CodeQL security alert js/log-injection

Co-authored-by: GitHub Copilot Autofix <noreply@github.com>
2025-11-07 22:35:53 +01:00
Gauvain
118c24ee05 Potential fix for code scanning alert no. 219: Workflow does not contain permissions
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-10-26 15:32:43 +01:00
renovate[bot]
61d60c2e74 chore(deps): Update dependency @biomejs/biome to v2.2.7 (#1142)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 14:39:42 +01:00
47 changed files with 758 additions and 1133 deletions

View File

@@ -1,4 +1,5 @@
name: 🛎️ Discord Notification name: 🛎️ Discord Notification
permissions: {}
on: on:
pull_request: pull_request:

27
.gitignore vendored
View File

@@ -1,34 +1,7 @@
# Dependencies and Package Managers # Dependencies and Package Managers
node_modules/ node_modules/
<<<<<<< HEAD
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
modules/vlc-player/android/build
# macOS
.DS_Store
# VSCode settings
.vscode/settings.json
expo-env.d.ts
Streamyfin.app
*.mp4
Streamyfin.app
=======
bun.lock bun.lock
bun.lockb bun.lockb
>>>>>>> origin/develop
package-lock.json package-lock.json
# Expo and React Native Build Artifacts # Expo and React Native Build Artifacts

View File

@@ -26,13 +26,6 @@
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
// SonarLint connected mode (kept from HEAD)
"sonarlint.connectedMode.project": {
"connectionId": "gauvino",
"projectKey": "Gauvino_streamyfin"
},
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome", "editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true "editor.formatOnSave": true

View File

@@ -7,7 +7,7 @@ import {
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
@@ -23,26 +23,7 @@ import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
interface HeaderRightProps { export default function page() {
readonly downloadedFiles: DownloadedItem[] | null;
readonly onPress: () => void;
}
function HeaderRight({ downloadedFiles, onPress }: HeaderRightProps) {
return (
<TouchableOpacity onPress={onPress}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
);
}
function CustomBottomSheetBackdrop(props: Readonly<BottomSheetBackdropProps>) {
return (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
);
}
const DownloadsPage = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
@@ -55,17 +36,6 @@ const DownloadsPage = () => {
const router = useRouter(); const router = useRouter();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const handleRemoveQueueItem = useCallback(
(queueItemId: string) => {
removeProcess(queueItemId);
setQueue((prev) => {
if (!prev) return [];
return prev.filter((i) => i.id !== queueItemId);
});
},
[removeProcess, setQueue],
);
const [showMigration, setShowMigration] = useState(false); const [showMigration, setShowMigration] = useState(false);
const migration_20241124 = () => { const migration_20241124 = () => {
@@ -83,13 +53,9 @@ const DownloadsPage = () => {
{ {
text: t("home.downloads.delete"), text: t("home.downloads.delete"),
style: "destructive", style: "destructive",
onPress: () => { onPress: async () => {
deleteAllFiles() await deleteAllFiles();
.then(() => setShowMigration(false)) setShowMigration(false);
.catch((error) => {
console.error("Failed to delete all files:", error);
setShowMigration(false);
});
}, },
}, },
], ],
@@ -137,21 +103,15 @@ const DownloadsPage = () => {
} }
}, [downloadedFiles]); }, [downloadedFiles]);
const headerRightComponent = useMemo(
() => (
<HeaderRight
downloadedFiles={downloadedFiles}
onPress={() => bottomSheetModalRef.current?.present()}
/>
),
[downloadedFiles],
);
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRightComponent, headerRight: () => (
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
</TouchableOpacity>
),
}); });
}, [headerRightComponent, navigation]); }, [downloadedFiles]);
useEffect(() => { useEffect(() => {
if (showMigration) { if (showMigration) {
@@ -220,13 +180,13 @@ const DownloadsPage = () => {
{t("home.downloads.queue_hint")} {t("home.downloads.queue_hint")}
</Text> </Text>
<View className='flex flex-col space-y-2 mt-2'> <View className='flex flex-col space-y-2 mt-2'>
{queue.map((q) => ( {queue.map((q, index) => (
<TouchableOpacity <TouchableOpacity
onPress={() => onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`) router.push(`/(auth)/items/page?id=${q.item.Id}`)
} }
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between' className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
key={q.id} key={index}
> >
<View> <View>
<Text className='font-semibold'>{q.item.Name}</Text> <Text className='font-semibold'>{q.item.Name}</Text>
@@ -235,7 +195,13 @@ const DownloadsPage = () => {
</Text> </Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
onPress={() => handleRemoveQueueItem(q.id)} onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...prev.filter((i) => i.id !== q.id)];
});
}}
> >
<Ionicons name='close' size={24} color='red' /> <Ionicons name='close' size={24} color='red' />
</TouchableOpacity> </TouchableOpacity>
@@ -354,7 +320,13 @@ const DownloadsPage = () => {
backgroundStyle={{ backgroundStyle={{
backgroundColor: "#171717", backgroundColor: "#171717",
}} }}
backdropComponent={CustomBottomSheetBackdrop} backdropComponent={(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
)}
> >
<BottomSheetView> <BottomSheetView>
<View className='p-4 space-y-4 mb-4'> <View className='p-4 space-y-4 mb-4'>
@@ -377,6 +349,4 @@ const DownloadsPage = () => {
</BottomSheetModal> </BottomSheetModal>
</> </>
); );
}; }
export default DownloadsPage;

View File

@@ -1,7 +1,7 @@
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, 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";
@@ -25,19 +25,7 @@ import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
interface LogoutButtonProps { export default function settings() {
readonly onPress: () => void;
}
function LogoutButton({ onPress }: LogoutButtonProps) {
return (
<TouchableOpacity onPress={onPress}>
<Text className='text-red-600'>{t("home.settings.log_out_button")}</Text>
</TouchableOpacity>
);
}
const SettingsPage = () => {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom); const [_user] = useAtom(userAtom);
@@ -49,17 +37,22 @@ const SettingsPage = () => {
successHapticFeedback(); successHapticFeedback();
}; };
const headerRightComponent = useMemo(
() => <LogoutButton onPress={() => logout()} />,
[logout],
);
const navigation = useNavigation(); const navigation = useNavigation();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRightComponent, headerRight: () => (
<TouchableOpacity
onPress={() => {
logout();
}}
>
<Text className='text-red-600'>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity>
),
}); });
}, [headerRightComponent, navigation]); }, []);
return ( return (
<ScrollView <ScrollView
@@ -125,6 +118,4 @@ const SettingsPage = () => {
</View> </View>
</ScrollView> </ScrollView>
); );
}; }
export default SettingsPage;

View File

@@ -1,18 +1,3 @@
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
interface HeaderRightProps {
readonly details: MovieDetails | TvDetails | null | undefined;
}
function HeaderRight({ details }: HeaderRightProps) {
if (!details) return null;
return (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
);
}
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
@@ -57,6 +42,7 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal"; import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants"; import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -78,7 +64,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>(); const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>(); const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, setRequestBody] = useState<MediaRequestBody>(); const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null); const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -129,18 +115,18 @@ const Page: React.FC = () => {
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleSetRequestBody = useCallback( const setRequestBody = useCallback(
(body: MediaRequestBody) => { (body: MediaRequestBody) => {
setRequestBody(body); _setRequestBody(body);
advancedReqModalRef?.current?.present?.(); advancedReqModalRef?.current?.present?.();
}, },
[requestBody, setRequestBody, advancedReqModalRef], [requestBody, _setRequestBody, advancedReqModalRef],
); );
const request = useCallback(async () => { const request = useCallback(async () => {
const body: MediaRequestBody = { const body: MediaRequestBody = {
mediaId: Number(result.id!), mediaId: Number(result.id!),
mediaType: mediaType, mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId, tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0) ?.filter?.((s) => s.seasonNumber !== 0)
@@ -148,12 +134,20 @@ const Page: React.FC = () => {
}; };
if (hasAdvancedRequestPermission) { if (hasAdvancedRequestPermission) {
handleSetRequestBody(body); setRequestBody(body);
return; return;
} }
requestMedia(mediaTitle, body, refetch); requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]); }, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
const isAnime = useMemo( const isAnime = useMemo(
() => () =>
@@ -162,81 +156,17 @@ const Page: React.FC = () => {
[details], [details],
); );
const headerRightComponent = useMemo(
() => <HeaderRight details={details as MovieDetails | TvDetails | null} />,
[details],
);
useEffect(() => { useEffect(() => {
navigation.setOptions({ if (details) {
headerRight: () => headerRightComponent, navigation.setOptions({
}); headerRight: () => (
}, [headerRightComponent, navigation]); <TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
const renderActionButton = () => { </TouchableOpacity>
if (isLoading || isFetching) { ),
return ( });
<Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
);
} }
}, [details]);
if (canRequest) {
return (
<Button color='purple' onPress={request} className='mt-4'>
{t("jellyseerr.request_button")}
</Button>
);
}
if (details?.mediaInfo?.jellyfinMediaId) {
return (
<View className='flex flex-row space-x-2 mt-4'>
{!Platform.isTV && (
<Button
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
color='transparent'
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name='warning-outline' size={20} color='white' />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>
{t("jellyseerr.report_issue_button")}
</Text>
</Button>
)}
<Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => {
const url =
mediaType === MediaType.MOVIE
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
router.push(url as any);
}}
iconLeft={<Ionicons name='play-outline' size={20} color='white' />}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
<Text className='text-sm'>Play</Text>
</Button>
</View>
);
}
return null;
};
return ( return (
<View <View
@@ -388,7 +318,7 @@ const Page: React.FC = () => {
details={details as TvDetails} details={details as TvDetails}
refetch={refetch} refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission} hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => handleSetRequestBody(data)} onAdvancedRequest={(data) => setRequestBody(data)}
/> />
)} )}
<DetailFacts <DetailFacts
@@ -407,11 +337,11 @@ const Page: React.FC = () => {
type={mediaType} type={mediaType}
isAnime={isAnime} isAnime={isAnime}
onRequested={() => { onRequested={() => {
setRequestBody(undefined); _setRequestBody(undefined);
advancedReqModalRef?.current?.close(); advancedReqModalRef?.current?.close();
refetch(); refetch();
}} }}
onDismiss={() => setRequestBody(undefined)} onDismiss={() => _setRequestBody(undefined)}
/> />
{!Platform.isTV && ( {!Platform.isTV && (
// This is till it's fixed because the menu isn't selectable on TV // This is till it's fixed because the menu isn't selectable on TV

View File

@@ -19,49 +19,9 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
function MissingDownloadIcon() { const page: React.FC = () => {
return <Ionicons name='download' size={22} color='white' />;
}
function DownloadedIcon() {
return <Ionicons name='checkmark-done-outline' size={24} color='#9333ea' />;
}
interface SeriesHeaderRightProps {
readonly isLoading: boolean;
readonly item: any;
readonly allEpisodes: any[];
}
function SeriesHeaderRight({
isLoading,
item,
allEpisodes,
}: SeriesHeaderRightProps) {
const { t } = useTranslation();
if (isLoading || !item || !allEpisodes || allEpisodes.length === 0) {
return null;
}
return (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes}
MissingDownloadIconComponent={MissingDownloadIcon}
DownloadedIconComponent={DownloadedIcon}
/>
)}
</View>
);
}
const SeriesPage: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId, seasonIndex } = params as {
id: string; id: string;
@@ -125,22 +85,36 @@ const SeriesPage: React.FC = () => {
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
const headerRightComponent = useMemo(
() => (
<SeriesHeaderRight
isLoading={isLoading}
item={item}
allEpisodes={allEpisodes || []}
/>
),
[isLoading, item, allEpisodes],
);
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => headerRightComponent, headerRight: () =>
!isLoading &&
item &&
allEpisodes &&
allEpisodes.length > 0 && (
<View className='flex flex-row items-center space-x-2'>
<AddToFavorites item={item} />
{!Platform.isTV && (
<DownloadItems
size='large'
title={t("item_card.download.download_series")}
items={allEpisodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name='download' size={22} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons
name='checkmark-done-outline'
size={24}
color='#9333ea'
/>
)}
/>
)}
</View>
),
}); });
}, [headerRightComponent, navigation]); }, [allEpisodes, isLoading, item]);
if (!item || !backdropUrl) return null; if (!item || !backdropUrl) return null;
@@ -184,4 +158,4 @@ const SeriesPage: React.FC = () => {
); );
}; };
export default SeriesPage; export default page;

View File

@@ -47,7 +47,7 @@ import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native"; import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
export default function Page() { export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null); const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom); const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
@@ -55,7 +55,7 @@ export default function Page() {
const navigation = useNavigation(); const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [aspectRatio, setAspectRatio] = useState< const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9" "default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default"); >("default");
@@ -81,13 +81,10 @@ export default function Page() {
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const setShowControlsWithHaptic = useCallback( const setShowControls = useCallback((show: boolean) => {
(show: boolean) => { _setShowControls(show);
setShowControls(show); lightHapticFeedback();
lightHapticFeedback(); }, []);
},
[lightHapticFeedback, setShowControls],
);
const { const {
itemId, itemId,
@@ -147,7 +144,7 @@ export default function Page() {
if (offline && !Platform.isTV) { if (offline && !Platform.isTV) {
const data = downloadUtils.getDownloadedItemById(itemId); const data = downloadUtils.getDownloadedItemById(itemId);
if (data) { if (data) {
fetchedItem = data.item; fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data); setDownloadedItem(data);
} }
} else { } else {
@@ -182,82 +179,64 @@ export default function Page() {
isError: false, isError: false,
}); });
const createOfflineStream = useCallback(() => {
if (!downloadedItem?.mediaSource || !item) return null;
return {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: downloadedItem.videoFilePath,
};
}, [downloadedItem, item]);
const validateStreamingRequirements = useCallback(() => {
if (!api) {
console.warn("API not available for streaming");
return false;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
return false;
}
return true;
}, [api, user?.Id]);
const fetchOnlineStream = useCallback(async () => {
if (!validateStreamingRequirements() || !user?.Id) return null;
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
startTimeTicks: getInitialPlaybackTicks(),
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
return null;
}
return { mediaSource, sessionId, url };
}, [
validateStreamingRequirements,
api,
item,
getInitialPlaybackTicks,
user?.Id,
audioIndex,
bitrateValue,
mediaSourceId,
subtitleIndex,
t,
]);
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
try { try {
// Don't attempt to fetch stream data if item is not available
if (!item?.Id) { if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch"); console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
return; return;
} }
const result = offline let result: Stream | null = null;
? createOfflineStream() if (offline && downloadedItem && downloadedItem.mediaSource) {
: await fetchOnlineStream(); const url = downloadedItem.videoFilePath;
if (item) {
result = {
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
}
} else {
// Validate required parameters before calling getStreamUrl
if (!api) {
console.warn("API not available for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
}
if (!user?.Id) {
console.warn("User not authenticated for streaming");
setStreamStatus({ isLoading: false, isError: true });
return;
}
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({
api,
item,
startTimeTicks: getInitialPlaybackTicks(),
userId: user.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result); setStream(result);
setStreamStatus({ isLoading: false, isError: false }); setStreamStatus({ isLoading: false, isError: false });
} catch (error) { } catch (error) {
@@ -274,9 +253,6 @@ export default function Page() {
item, item,
user?.Id, user?.Id,
downloadedItem, downloadedItem,
offline,
createOfflineStream,
fetchOnlineStream,
]); ]);
useEffect(() => { useEffect(() => {
@@ -345,8 +321,8 @@ export default function Page() {
if (!stream) return; if (!stream) return;
return { return {
itemId: item?.Id!, itemId: item?.Id!,
audioStreamIndex: audioIndex || undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex || undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()), positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying, isPaused: !isPlaying,
@@ -502,46 +478,15 @@ export default function Page() {
} }
}, []); }, []);
const handleTogglePlay = useCallback(() => {
togglePlay().catch((error) => console.error("Error toggling play:", error));
}, [togglePlay]);
const handleToggleMute = useCallback(() => {
toggleMuteCb().catch((error) =>
console.error("Error toggling mute:", error),
);
}, [toggleMuteCb]);
const handleVolumeUp = useCallback(() => {
volumeUpCb().catch((error) =>
console.error("Error increasing volume:", error),
);
}, [volumeUpCb]);
const handleVolumeDown = useCallback(() => {
volumeDownCb().catch((error) =>
console.error("Error decreasing volume:", error),
);
}, [volumeDownCb]);
const handleSetVolume = useCallback(
(volume: number) => {
setVolumeCb(volume).catch((error) =>
console.error("Error setting volume:", error),
);
},
[setVolumeCb],
);
useWebSocket({ useWebSocket({
isPlaying: isPlaying, isPlaying: isPlaying,
togglePlay: handleTogglePlay, togglePlay: togglePlay,
stopPlayback: stop, stopPlayback: stop,
offline, offline,
toggleMute: handleToggleMute, toggleMute: toggleMuteCb,
volumeUp: handleVolumeUp, volumeUp: volumeUpCb,
volumeDown: handleVolumeDown, volumeDown: volumeDownCb,
setVolume: handleSetVolume, setVolume: setVolumeCb,
}); });
const onPlaybackStateChanged = useCallback( const onPlaybackStateChanged = useCallback(
@@ -788,6 +733,7 @@ export default function Page() {
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
videoRef={videoRef}
togglePlay={togglePlay} togglePlay={togglePlay}
isPlaying={isPlaying} isPlaying={isPlaying}
isSeeking={isSeeking} isSeeking={isSeeking}
@@ -795,12 +741,13 @@ export default function Page() {
cacheProgress={cacheProgress} cacheProgress={cacheProgress}
isBuffering={isBuffering} isBuffering={isBuffering}
showControls={showControls} showControls={showControls}
setShowControls={setShowControlsWithHaptic} setShowControls={setShowControls}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={startPictureInPicture} startPictureInPicture={startPictureInPicture}
play={play} play={play}
pause={pause} pause={pause}
seek={seek} seek={seek}
enableTrickplay={true}
getAudioTracks={getAudioTracks} getAudioTracks={getAudioTracks}
getSubtitleTracks={getSubtitleTracks} getSubtitleTracks={getSubtitleTracks}
offline={offline} offline={offline}

View File

@@ -5,7 +5,7 @@ import { type PropsWithChildren } from "react";
* This file is web-only and used to configure the root HTML for every web page during static rendering. * This file is web-only and used to configure the root HTML for every web page during static rendering.
* The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs. * The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs.
*/ */
export default function Root({ children }: Readonly<PropsWithChildren>) { export default function Root({ children }: PropsWithChildren) {
return ( return (
<html lang='en'> <html lang='en'>
<head> <head>

View File

@@ -1,7 +1,7 @@
import "@/augmentations"; import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { Appearance, AppState, Platform } from "react-native"; import { Platform } from "react-native";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
@@ -9,7 +9,6 @@ import {
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider, JellyfinProvider,
userAtom,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
@@ -46,9 +45,10 @@ import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager"; import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai"; import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -60,7 +60,9 @@ import type {
NotificationResponse, NotificationResponse,
} from "expo-notifications/build/Notifications.types"; } from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types"; import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store"; import { store } from "@/utils/store";
if (!Platform.isTV) { if (!Platform.isTV) {
@@ -279,7 +281,7 @@ function Layout() {
return; return;
} }
if (!Platform.isTV && user?.Policy?.IsAdministrator) { if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions(); await registerBackgroundFetchAsyncSessions();
} }
@@ -316,7 +318,7 @@ function Layout() {
writeInfoLog(`Notification ${title} opened`, data); writeInfoLog(`Notification ${title} opened`, data);
let url: any; let url: any;
const type = String(data?.type ?? "").toLowerCase(); const type = (data?.type ?? "").toString().toLowerCase();
const itemId = data?.id; const itemId = data?.id;
switch (type) { switch (type) {
@@ -327,13 +329,13 @@ function Layout() {
// `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`; // `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
// We just clicked a notification for an individual episode. // We just clicked a notification for an individual episode.
if (itemId) { if (itemId) {
url = `/(auth)/(tabs)/home/items/page?id=${String(itemId)}`; url = `/(auth)/(tabs)/home/items/page?id=${itemId}`;
// summarized season notification for multiple episodes. Bring them to series season // summarized season notification for multiple episodes. Bring them to series season
} else { } else {
const seriesId = data.seriesId; const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex; const seasonIndex = data.seasonIndex;
if (seasonIndex) { if (seasonIndex) {
url = `/(auth)/(tabs)/home/series/${String(seriesId)}?seasonIndex=${String(seasonIndex)}`; url = `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`;
} else { } else {
url = `/(auth)/(tabs)/home/series/${seriesId}`; url = `/(auth)/(tabs)/home/series/${seriesId}`;
} }

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json", "$schema": "https://biomejs.dev/schemas/2.2.7/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**/*", "**/*",

View File

@@ -83,7 +83,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.5", "@babel/core": "7.28.5",
"@biomejs/biome": "2.2.6", "@biomejs/biome": "2.2.7",
"@react-native-community/cli": "20.0.2", "@react-native-community/cli": "20.0.2",
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
@@ -293,23 +293,23 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@biomejs/biome": ["@biomejs/biome@2.2.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.6", "@biomejs/cli-darwin-x64": "2.2.6", "@biomejs/cli-linux-arm64": "2.2.6", "@biomejs/cli-linux-arm64-musl": "2.2.6", "@biomejs/cli-linux-x64": "2.2.6", "@biomejs/cli-linux-x64-musl": "2.2.6", "@biomejs/cli-win32-arm64": "2.2.6", "@biomejs/cli-win32-x64": "2.2.6" }, "bin": { "biome": "bin/biome" } }, "sha512-yKTCNGhek0rL5OEW1jbLeZX8LHaM8yk7+3JRGv08my+gkpmtb5dDE+54r2ZjZx0ediFEn1pYBOJSmOdDP9xtFw=="], "@biomejs/biome": ["@biomejs/biome@2.2.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.7", "@biomejs/cli-darwin-x64": "2.2.7", "@biomejs/cli-linux-arm64": "2.2.7", "@biomejs/cli-linux-arm64-musl": "2.2.7", "@biomejs/cli-linux-x64": "2.2.7", "@biomejs/cli-linux-x64-musl": "2.2.7", "@biomejs/cli-win32-arm64": "2.2.7", "@biomejs/cli-win32-x64": "2.2.7" }, "bin": { "biome": "bin/biome" } }, "sha512-1a8j0UP1vXVUf3UzMZEJ/zS2VgAG6wU6Cuh/I764sUGI+MCnJs/9WaojHYBDCxCMLTgU60/WqnYof85emXmSBA=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UZPmn3M45CjTYulgcrFJFZv7YmK3pTxTJDrFYlNElT2FNnkkX4fsxjExTSMeWKQYoZjvekpH5cvrYZZlWu3yfA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xBUUsebnO2/Qj1v7eZmKUy2ZcFkZ4/jLUkxN02Qup1RPoRaiW9AKXHrqS3L7iX6PzofHY2xuZ+Pb9kAcpoe0qA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HOUIquhHVgh/jvxyClpwlpl/oeMqntlteL89YqjuFDiZ091P0vhHccwz+8muu3nTyHWM5FQslt+4Jdcd67+xWQ=="], "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-vsY4NhmxqgfLJufr9XUnC+yGUPJiXAc1mz6FcjaAmuIuLwfghN4uQO7hnW2AneGyoi2mNe9Jbvf6Qtq4AjzrFg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-BpGtuMJGN+o8pQjvYsUKZ+4JEErxdSmcRD/JG3mXoWc6zrcA7OkuyGFN1mDggO0Q1n7qXxo/PcupHk8gzijt5g=="], "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nUdco104rjV9dULi1VssQ5R/kX2jE/Z2sDjyqS+siV9sTQda0DwmEUixFNRCWvZJRRiZUWhgiDFJ4n7RowO8Mg=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjCenQq3N6g1C+5UT3jE1bIiJb5MWQvulpUngTIpFsL4StVAUXucWD0SL9MCW89Tm6awWfeXBbZBAhJwjyFbRQ=="], "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-FrTwvKO/7t5HbVTvhlMOTOVQLAcR7r4O4iFQhEpZXUtBfosHqrX/JJlX7daPawoe14MDcCu9CDg0zLVpTuDvuQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1HaM/dpI/1Z68zp8ZdT6EiBq+/O/z97a2AiHMl+VAdv5/ELckFt9EvRb8hDHpk8hUMoz03gXkC7VPXOVtU7faA=="], "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tPTcGAIEOOZrj2tQ7fdraWlaxNKApBw6l4In8wQQV1IyxnAexqi0hykHzKEX8hKKctf5gxGBfNCzyIvqpj4CFQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1ZcBux8zVM3JhWN2ZCPaYf0+ogxXG316uaoXJdgoPZcdK/rmRcRY7PqHdAos2ExzvjIdvhQp72UcveI98hgOog=="], "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-MnsysF5s/iLC5wnYvuMseOy+m8Pd4bWG1uwlVyy2AUbfjAVUgtbYbboc5wMXljFrDY7e6rLjLTR4S2xqDpGlQg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-h3A88G8PGM1ryTeZyLlSdfC/gz3e95EJw9BZmA6Po412DRqwqPBa2Y9U+4ZSGUAXCsnSQE00jLV8Pyrh0d+jQw=="], "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-h5D1jhwA2b7cFXerYiJfXHSzzAMFFoEDL5Mc2BgiaEw0iaSgSso/3Nc6FbOR55aTQISql+IpB4PS7JoV26Gdbw=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yx0CqeOhPjYQ5ZXgPfu8QYkgBhVJyvWe36as7jRuPrKPO5ylVDfwVtPQ+K/mooNTADW0IhxOZm3aPu16dP8yNQ=="], "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-URqAJi0kONyBKG4V9NVafHLDtm6IHmF4qPYi/b6x7MD6jxpWeJiTCO6R5+xDlWckX2T/OGv6Yq3nkz6s0M8Ykw=="],
"@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.11.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-xjRZZe3GZ/bIADBkJSe+qjRC/pQKcTEhZgtoGb4lyINq1NPzhKXhlZHwZLzNJng/Q/+F4RD3M7bQ6oCgSHV2WA=="], "@bottom-tabs/react-navigation": ["@bottom-tabs/react-navigation@0.11.2", "", { "dependencies": { "color": "^5.0.0" }, "peerDependencies": { "@react-navigation/native": ">=7", "react": "*", "react-native": "*", "react-native-bottom-tabs": "*" } }, "sha512-xjRZZe3GZ/bIADBkJSe+qjRC/pQKcTEhZgtoGb4lyINq1NPzhKXhlZHwZLzNJng/Q/+F4RD3M7bQ6oCgSHV2WA=="],

View File

@@ -307,7 +307,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const navigateToItem = useCallback( const navigateToItem = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)"); const navigation = getItemNavigation(item, "(home)");
router.push(navigation as `/(auth)/(tabs)/${string}`); router.push(navigation as any);
}, },
[router], [router],
); );

View File

@@ -56,7 +56,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
useNativeDriver: true, useNativeDriver: true,
}).start(); }).start();
const getColorClasses = (color: string, focused: boolean) => { const colorClasses = useMemo(() => {
switch (color) { switch (color) {
case "purple": case "purple":
return focused return focused
@@ -68,38 +68,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
return "bg-neutral-900"; return "bg-neutral-900";
case "transparent": case "transparent":
return "bg-transparent"; return "bg-transparent";
default:
return "bg-purple-600 border border-purple-700";
} }
}; }, [color, focused]);
const colorClasses = useMemo(
() => getColorClasses(color, focused),
[color, focused],
);
const lightHapticFeedback = useHaptic("light"); const lightHapticFeedback = useHaptic("light");
const handlePress = () => { return Platform.isTV ? (
if (!loading && !disabled && onPress) {
onPress();
lightHapticFeedback();
}
};
const getTextClasses = () => {
const baseClasses = "text-white font-bold text-base";
const disabledClass = disabled ? " text-gray-300" : "";
const rightMargin = iconRight ? " mr-2" : "";
const leftMargin = iconLeft ? " ml-2" : "";
return `${baseClasses}${disabledClass} ${textClassName}${rightMargin}${leftMargin}`;
};
const getJustifyClass = () => {
return justify === "between" ? "justify-between" : "justify-center";
};
const renderTVButton = () => (
<Pressable <Pressable
className='w-full' className='w-full'
onPress={onPress} onPress={onPress}
@@ -119,7 +93,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0, shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0, shadowRadius: focused ? 18 : 0,
elevation: focused ? 12 : 0, elevation: focused ? 12 : 0, // Android glow
}} }}
> >
<View <View
@@ -131,9 +105,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
</View> </View>
</Animated.View> </Animated.View>
</Pressable> </Pressable>
); ) : (
const renderTouchButton = () => (
<TouchableOpacity <TouchableOpacity
className={` className={`
p-3 rounded-xl items-center justify-center p-3 rounded-xl items-center justify-center
@@ -141,7 +113,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
${colorClasses} ${colorClasses}
${className} ${className}
`} `}
onPress={handlePress} onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
lightHapticFeedback();
}
}}
disabled={disabled || loading} disabled={disabled || loading}
{...props} {...props}
> >
@@ -151,15 +128,25 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
</View> </View>
) : ( ) : (
<View <View
className={`flex flex-row items-center justify-between w-full ${getJustifyClass()}`} className={`
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
> >
{iconLeft || <View className='w-4' />} {iconLeft ? iconLeft : <View className='w-4' />}
<Text className={getTextClasses()}>{children}</Text> <Text
{iconRight || <View className='w-4' />} className={`
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}
${iconLeft ? "ml-2" : ""}
`}
>
{children}
</Text>
{iconRight ? iconRight : <View className='w-4' />}
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
); );
return Platform.isTV ? renderTVButton() : renderTouchButton();
}; };

View File

@@ -6,9 +6,8 @@ import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, TouchableOpacity, View } from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import { import CastContext, {
CastButton, CastButton,
CastContext,
PlayServicesState, PlayServicesState,
useMediaStatus, useMediaStatus,
useRemoteMediaClient, useRemoteMediaClient,
@@ -47,63 +46,6 @@ interface Props extends React.ComponentProps<typeof Button> {
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15; const MIN_PLAYBACK_WIDTH = 15;
// Helper function to create media metadata for Chromecast
const createMediaMetadata = (item: BaseItemDto, api: any) => {
if (item.Type === "Episode") {
return {
type: "tvShow" as const,
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
};
}
if (item.Type === "Movie") {
return {
type: "movie" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
};
}
return {
type: "generic" as const,
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
};
};
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
@@ -144,86 +86,12 @@ export const PlayButton: React.FC<Props> = ({
[router, isOffline], [router, isOffline],
); );
const handleChromecast = useCallback(
async (params: {
item: BaseItemDto;
api: any;
user: any;
selectedOptions: SelectedOptions;
client: any;
t: any;
settings: any;
isOpeningCurrentlyPlayingMedia: boolean;
}) => {
const {
item,
api,
user,
selectedOptions,
client,
t,
settings,
isOpeningCurrentlyPlayingMedia,
} = params;
const enableH265 = settings.enableH265ForChromecast;
if (!api) {
console.warn("API not available for Chromecast streaming");
Alert.alert(t("player.client_error"), t("player.missing_parameters"));
return;
}
if (!user?.Id) {
console.warn("User not authenticated for Chromecast streaming");
Alert.alert(t("player.client_error"), t("player.missing_parameters"));
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(t("player.client_error"), t("player.missing_parameters"));
return;
}
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata: createMediaMetadata(item, api),
},
startTime: 0,
})
.then(() => {
if (isOpeningCurrentlyPlayingMedia) return;
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
},
[],
);
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
console.log("onPress");
if (!item) return; if (!item) return;
lightHapticFeedback(); lightHapticFeedback();
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id!, itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "", audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -233,11 +101,14 @@ export const PlayButton: React.FC<Props> = ({
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false", offline: isOffline ? "true" : "false",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();
if (!client) { if (!client) {
goToPlayer(queryString); goToPlayer(queryString);
return; return;
} }
const options = ["Chromecast", "Device", "Cancel"]; const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2; const cancelButtonIndex = 2;
showActionSheetWithOptions( showActionSheetWithOptions(
@@ -250,23 +121,137 @@ export const PlayButton: React.FC<Props> = ({
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title; const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia = const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name; currentTitle && currentTitle === item?.Name;
switch (selectedIndex) { switch (selectedIndex) {
case 0: case 0:
await CastContext.getPlayServicesState().then(async (state) => { await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) { if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state); CastContext.showPlayServicesErrorDialog(state);
} else { } else {
await handleChromecast({ // Check if user wants H265 for Chromecast
item, const enableH265 = settings.enableH265ForChromecast;
api,
user, // Validate required parameters before calling getStreamUrl
selectedOptions, if (!api) {
client, console.warn("API not available for Chromecast streaming");
t, Alert.alert(
settings, t("player.client_error"),
isOpeningCurrentlyPlayingMedia: t("player.missing_parameters"),
!!isOpeningCurrentlyPlayingMedia, );
}); return;
}
if (!user?.Id) {
console.warn(
"User not authenticated for Chromecast streaming",
);
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
if (!item?.Id) {
console.warn("Item not available for Chromecast streaming");
Alert.alert(
t("player.client_error"),
t("player.missing_parameters"),
);
return;
}
// Get a new URL with the Chromecast device profile
try {
const data = await getStreamUrl({
api,
item,
deviceProfile: enableH265 ? chromecasth265 : chromecast,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user.Id,
audioStreamIndex: selectedOptions.audioIndex,
maxStreamingBitrate: selectedOptions.bitrate?.value,
mediaSourceId: selectedOptions.mediaSource?.Id,
subtitleStreamIndex: selectedOptions.subtitleIndex,
});
console.log("URL: ", data?.url, enableH265);
if (!data?.url) {
console.warn("No URL returned from getStreamUrl", data);
Alert.alert(
t("player.client_error"),
t("player.could_not_create_stream_for_chromecast"),
);
return;
}
client
.loadMedia({
mediaInfo: {
contentUrl: data?.url,
contentType: "video/mp4",
metadata:
item.Type === "Episode"
? {
type: "tvShow",
title: item.Name || "",
episodeNumber: item.IndexNumber || 0,
seasonNumber: item.ParentIndexNumber || 0,
seriesTitle: item.SeriesName || "",
images: [
{
url: getParentBackdropImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: item.Type === "Movie"
? {
type: "movie",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
}
: {
type: "generic",
title: item.Name || "",
subtitle: item.Overview || "",
images: [
{
url: getPrimaryImageUrl({
api,
item,
quality: 90,
width: 2000,
})!,
},
],
},
},
startTime: 0,
})
.then(() => {
// state is already set when reopening current media, so skip it here.
if (isOpeningCurrentlyPlayingMedia) {
return;
}
CastContext.showExpandedControls();
});
} catch (e) {
console.log(e);
}
} }
}); });
break; break;
@@ -288,14 +273,10 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions, showActionSheetWithOptions,
mediaStatus, mediaStatus,
selectedOptions, selectedOptions,
lightHapticFeedback,
goToPlayer,
isOffline,
handleChromecast,
]); ]);
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0; if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData; const userData = item.UserData;
if (userData?.PlaybackPositionTicks) { if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0

View File

@@ -76,10 +76,11 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString(); const queryString = queryParams.toString();
goToPlayer(queryString); goToPlayer(queryString);
return;
}; };
const derivedTargetWidth = useDerivedValue(() => { const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0; if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData; const userData = item.UserData;
if (userData?.PlaybackPositionTicks) { if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0 return userData.PlaybackPositionTicks > 0

View File

@@ -45,7 +45,7 @@ export function InfiniteHorizontalScroll({
loading = false, loading = false,
height = 164, height = 164,
...props ...props
}: Readonly<HorizontalScrollProps>): React.ReactElement { }: HorizontalScrollProps): React.ReactElement {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);

View File

@@ -16,7 +16,6 @@ import type {
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv"; import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { getCurrentTab } from "@/utils/navigation";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast; result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
@@ -41,8 +40,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
const segments = useSegments(); const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
// Some segment arrays may have fewer than 3 elements; fall back to home tab. const from = (segments as string[])[2] || "(home)";
const from = getCurrentTab(segments as string[]);
const autoApprove = useMemo(() => { const autoApprove = useMemo(() => {
return ( return (
@@ -71,13 +69,14 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
// @ts-expect-error
params: { params: {
...result,
mediaTitle, mediaTitle,
releaseYear: releaseYear.toString(), releaseYear,
canRequest: canRequest ? "true" : "false", canRequest: canRequest.toString(),
posterSrc, posterSrc,
mediaType: mediaType.toString(), mediaType,
id: result.id?.toString?.() ?? undefined,
}, },
}); });
}} }}

View File

@@ -1,5 +1,5 @@
import { Platform, Text as RNText, type TextProps } from "react-native"; import { Platform, Text as RNText, type TextProps } from "react-native";
export function Text(props: Readonly<TextProps>) { export function Text(props: TextProps) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
if (Platform.isTV) if (Platform.isTV)
return ( return (

View File

@@ -5,7 +5,6 @@ import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { getCurrentTab } from "@/utils/navigation";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
@@ -44,8 +43,47 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`; return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
}; };
// Export alias for backward compatibility export const getItemNavigation = (item: BaseItemDto, _from: string) => {
export const getItemNavigation = itemRouter; if ("CollectionType" in item && item.CollectionType === "livetv") {
return {
pathname: "/livetv" as const,
};
}
if (item.Type === "Series") {
return {
pathname: "/series/[id]" as const,
params: { id: item.Id! },
};
}
if (item.Type === "Person") {
return {
pathname: "/persons/[personId]" as const,
params: { personId: item.Id! },
};
}
if (item.Type === "BoxSet" || item.Type === "UserView") {
return {
pathname: "/collections/[collectionId]" as const,
params: { collectionId: item.Id! },
};
}
if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
return {
pathname: "/[libraryId]" as const,
params: { libraryId: item.Id! },
};
}
// Default case - items page
return {
pathname: "/items/page" as const,
params: { id: item.Id! },
};
};
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item, item,
@@ -59,7 +97,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
const markAsPlayedStatus = useMarkAsPlayed([item]); const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item); const { isFavorite, toggleFavorite } = useFavorite(item);
const from = getCurrentTab(segments as string[]); const from = (segments as string[])[2] || "(home)";
const showActionSheet = useCallback(() => { const showActionSheet = useCallback(() => {
if ( if (
@@ -105,11 +143,15 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onLongPress={showActionSheet} onLongPress={showActionSheet}
onPress={() => { onPress={() => {
let url = itemRouter(item, from);
if (isOffline) { if (isOffline) {
url += `&offline=true`; // For offline mode, we still need to use query params
const url = `${itemRouter(item, from)}&offline=true`;
router.push(url as any);
return;
} }
router.push(url as `/(auth)/(tabs)/${string}`);
const navigation = getItemNavigation(item, from);
router.push(navigation as any);
}} }}
{...props} {...props}
> >

View File

@@ -21,8 +21,7 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getCurrentTab } from "@/utils/navigation"; import { getItemNavigation } from "../common/TouchableItemRouter";
import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {} interface Props extends ViewProps {}
@@ -148,16 +147,16 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getLogoImageUrlById({ api, item, height: 100 }); return getLogoImageUrlById({ api, item, height: 100 });
}, [item]); }, [item]);
const segments = useSegments() as string[]; const segments = useSegments();
const from = getCurrentTab(segments); const from = (segments as string[])[2] || "(home)";
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const handleRoute = useCallback(() => { const handleRoute = useCallback(() => {
if (!from) return; if (!from) return;
const url = itemRouter(item, from);
lightHapticFeedback(); lightHapticFeedback();
if (url) router.push(url as any); const navigation = getItemNavigation(item, from);
router.push(navigation as any);
}, [item, from]); }, [item, from]);
const tap = Gesture.Tap() const tap = Gesture.Tap()

View File

@@ -4,16 +4,18 @@ import {
type StyleProp, type StyleProp,
StyleSheet, StyleSheet,
Text, Text,
type TextInputProps,
View, View,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
interface PinInputProps { interface PinInputProps
readonly length: number; extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
readonly value: string; value: string;
readonly onChangeText: (text: string) => void; onChangeText: (text: string) => void;
readonly style?: StyleProp<ViewStyle>; length?: number;
readonly autoFocus?: boolean; autoFocus?: boolean;
style?: StyleProp<ViewStyle>;
} }
export interface PinInputRef { export interface PinInputRef {
@@ -63,7 +65,7 @@ const PinInputComponent = React.forwardRef<PinInputRef, PinInputProps>(
.fill(0) .fill(0)
.map((_, i) => ( .map((_, i) => (
<View <View
key={`pin-input-cell-${i}-${length}`} key={i}
style={[ style={[
styles.cell, styles.cell,
i === activeIndex && styles.activeCell, i === activeIndex && styles.activeCell,

View File

@@ -18,9 +18,7 @@ const BACKDROP_DURATION = 5000;
type Render = React.ComponentType<any> | React.ReactElement | null | undefined; type Render = React.ComponentType<any> | React.ReactElement | null | undefined;
const ItemSeparator = () => <View className='h-2 w-2' />; interface Props<T> {
interface ParallaxSlideShowProps<T> {
data: T[]; data: T[];
images: string[]; images: string[];
logo?: React.ReactElement; logo?: React.ReactElement;
@@ -29,7 +27,7 @@ interface ParallaxSlideShowProps<T> {
listHeader: string; listHeader: string;
renderItem: (item: T, index: number) => Render; renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string; keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null; onEndReached?: (() => void) | null | undefined;
} }
const ParallaxSlideShow = <T,>({ const ParallaxSlideShow = <T,>({
@@ -42,7 +40,7 @@ const ParallaxSlideShow = <T,>({
renderItem, renderItem,
keyExtractor, keyExtractor,
onEndReached, onEndReached,
}: PropsWithChildren<ParallaxSlideShowProps<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
@@ -68,21 +66,15 @@ const ParallaxSlideShow = <T,>({
[fadeAnim], [fadeAnim],
); );
const handleAnimationComplete = useCallback(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
}, [fadeAnim, images?.length, setCurrentIndex]);
const createSlideSequence = useCallback(() => {
return Animated.sequence([enterAnimation(), exitAnimation()]);
}, [enterAnimation, exitAnimation]);
useEffect(() => { useEffect(() => {
if (images?.length) { if (images?.length) {
enterAnimation().start(); enterAnimation().start();
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
createSlideSequence().start(handleAnimationComplete); Animated.sequence([enterAnimation(), exitAnimation()]).start(() => {
fadeAnim.setValue(0);
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
});
}, BACKDROP_DURATION); }, BACKDROP_DURATION);
return () => { return () => {
@@ -96,8 +88,6 @@ const ParallaxSlideShow = <T,>({
exitAnimation, exitAnimation,
setCurrentIndex, setCurrentIndex,
currentIndex, currentIndex,
createSlideSequence,
handleAnimationComplete,
]); ]);
return ( return (
@@ -149,20 +139,12 @@ const ParallaxSlideShow = <T,>({
} }
nestedScrollEnabled nestedScrollEnabled
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
renderItem={({ item, index }) => { //@ts-expect-error
const rendered = renderItem(item as any, index); renderItem={({ item, index }) => renderItem(item, index)}
if (!rendered) return null;
// If the result is a component type, instantiate it
if (typeof rendered === "function") {
const Comp: any = rendered;
return <Comp />;
}
return rendered as React.ReactElement;
}}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
numColumns={3} numColumns={3}
estimatedItemSize={214} estimatedItemSize={214}
ItemSeparatorComponent={ItemSeparator} ItemSeparatorComponent={() => <View className='h-2 w-2' />}
/> />
</View> </View>
</View> </View>

View File

@@ -4,7 +4,6 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster"; import Poster from "@/components/posters/Poster";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { getCurrentTab } from "@/utils/navigation";
interface Props { interface Props {
id: string; id: string;
@@ -22,8 +21,8 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
}) => { }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const router = useRouter(); const router = useRouter();
const segments = useSegments() as string[]; const segments = useSegments();
const from = getCurrentTab(segments); const from = (segments as string[])[2] || "(home)";
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (

View File

@@ -10,14 +10,13 @@ import {
type Network, type Network,
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider"; } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider"; import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import { getCurrentTab } from "@/utils/navigation";
const CompanySlide: React.FC< const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps { data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => { > = ({ slide, data, ...props }) => {
const segments = useSegments() as string[]; const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const from = getCurrentTab(segments); const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(
({ id, image, name }: Network | Studio) => ({ id, image, name }: Network | Studio) =>

View File

@@ -34,34 +34,37 @@ const GenericSlideCard: React.FC<
contentFit = "contain", contentFit = "contain",
...props ...props
}) => ( }) => (
<LinearGradient <>
colors={colors} <LinearGradient
start={{ x: 0.5, y: 1.75 }} colors={colors}
end={{ x: 0.5, y: 0 }} start={{ x: 0.5, y: 1.75 }}
className='rounded-xl' end={{ x: 0.5, y: 0 }}
> className='rounded-xl'
<View className='rounded-xl' {...props}> >
<Image <View className='rounded-xl' {...props}>
key={id} <Image
id={id} key={id}
source={url ? { uri: url } : null} id={id}
cachePolicy={"memory-disk"} source={url ? { uri: url } : null}
contentFit={contentFit} cachePolicy={"memory-disk"}
style={{ contentFit={contentFit}
aspectRatio: "4/3", style={{
}} aspectRatio: "4/3",
/> }}
{title && ( />
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'> {title && (
<Text <View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
className='text-center font-bold' <Text
style={textShadowStyle.shadow} className='text-center font-bold'
> style={textShadowStyle.shadow}
{title} >
</Text> {title}
</View> </Text>
)} </View>
</View> )}
</LinearGradient> </View>
</LinearGradient>
</>
); );
export default GenericSlideCard; export default GenericSlideCard;

View File

@@ -9,12 +9,11 @@ import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover"; import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants"; import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
import { getCurrentTab } from "@/utils/navigation";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => { const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments() as string[]; const segments = useSegments();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const from = getCurrentTab(segments); const from = (segments as string[])[2] || "(home)";
const navigate = useCallback( const navigate = useCallback(
(genre: GenreSliderItem) => (genre: GenreSliderItem) =>

View File

@@ -6,8 +6,16 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => { type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
profileName: string;
canRemove: boolean;
};
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({ const { data: details } = useQuery({
@@ -66,9 +74,17 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
<Slide <Slide
{...props} {...props}
slide={slide} slide={slide}
data={requests.results as MediaRequest[]} data={
requests.results.map((item) => ({
...item,
profileName: item.profileName ?? "Unknown",
canRemove: Boolean(item.canRemove),
})) as ExtendedMediaRequest[]
}
keyExtractor={(item) => item.id.toString()} keyExtractor={(item) => item.id.toString()}
renderItem={(item: MediaRequest) => <RequestCard request={item} />} renderItem={(item: ExtendedMediaRequest) => (
<RequestCard request={item} />
)}
/> />
) )
); );

View File

@@ -20,7 +20,7 @@ interface Props<T> extends SlideProps {
index: number, index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined; ) => React.ComponentType<any> | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string; keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null; onEndReached?: (() => void) | null | undefined;
} }
const Slide = <T,>({ const Slide = <T,>({
@@ -41,7 +41,7 @@ const Slide = <T,>({
horizontal horizontal
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 16, paddingHorizontal: 16,
...(contentContainerStyle ?? {}), ...(contentContainerStyle ? contentContainerStyle : {}),
}} }}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
@@ -49,16 +49,10 @@ const Slide = <T,>({
data={data} data={data}
onEndReachedThreshold={1} onEndReachedThreshold={1}
onEndReached={onEndReached} onEndReached={onEndReached}
renderItem={({ item, index }) => { //@ts-expect-error
if (!item) return null; renderItem={({ item, index }) =>
const rendered = renderItem(item, index); item ? renderItem(item, index) : null
if (!rendered) return null; }
if (typeof rendered === "function") {
const Comp: any = rendered;
return <Comp />;
}
return rendered;
}}
/> />
</View> </View>
); );

View File

@@ -16,10 +16,7 @@ import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest"; import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker"; import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
@@ -126,11 +123,11 @@ const JellyseerrPoster: React.FC<Props> = ({
return ( return (
<TouchableJellyseerrRouter <TouchableJellyseerrRouter
result={item} result={item}
mediaTitle={title || ""} mediaTitle={title}
releaseYear={releaseYear} releaseYear={releaseYear}
canRequest={canRequest} canRequest={canRequest}
posterSrc={posterSrc || ""} posterSrc={posterSrc!}
mediaType={mediaType || MediaType.MOVIE} mediaType={mediaType}
> >
<View className={"flex flex-col mr-2 h-auto"}> <View className={"flex flex-col mr-2 h-auto"}>
<View <View
@@ -194,7 +191,7 @@ const JellyseerrPoster: React.FC<Props> = ({
/> />
<JellyseerrMediaIcon <JellyseerrMediaIcon
className='absolute top-1 left-1' className='absolute top-1 left-1'
mediaType={mediaType as "movie" | "tv"} mediaType={mediaType}
/> />
</View> </View>
</View> </View>

View File

@@ -10,10 +10,8 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native"; import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl"; import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getCurrentTab } from "@/utils/navigation";
import { HorizontalScroll } from "../common/HorizontalScroll"; import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster"; import Poster from "../posters/Poster";
interface Props extends ViewProps { interface Props extends ViewProps {
@@ -23,9 +21,9 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => { export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const segments = useSegments() as string[]; const segments = useSegments();
const { t } = useTranslation(); const { t } = useTranslation();
const from = getCurrentTab(segments); const from = (segments as string[])[2];
const destinctPeople = useMemo(() => { const destinctPeople = useMemo(() => {
const people: Record<string, BaseItemPerson> = {}; const people: Record<string, BaseItemPerson> = {};
@@ -57,14 +55,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
renderItem={(i) => ( renderItem={(i) => (
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
const url = itemRouter( if (i.Id) {
{ router.push({
Id: i.Id, pathname: "/persons/[personId]",
Type: "Person", params: { personId: i.Id },
}, });
from, }
);
router.push(url as any);
}} }}
className='flex flex-col w-28' className='flex flex-col w-28'
> >

View File

@@ -1,14 +1,7 @@
const MissingDownloadIconComponent = () => (
<Ionicons name='download' size={20} color='white' />
);
const DownloadedIconComponent = () => (
<Ionicons name='download' size={20} color='#9333ea' />
);
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -77,7 +70,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
if (!season?.Id) return null; if (!season?.Id) return null;
return season.Id; return season.Id!;
}, [seasons, seasonIndex]); }, [seasons, seasonIndex]);
const { data: episodes, isPending } = useQuery({ const { data: episodes, isPending } = useQuery({
@@ -107,8 +100,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
}); });
const _queryClient = useQueryClient();
// Used for height calculation // Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0); const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => { useEffect(() => {
@@ -142,8 +133,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
title={t("item_card.download.download_season")} title={t("item_card.download.download_season")}
className='ml-2' className='ml-2'
items={episodes || []} items={episodes || []}
MissingDownloadIconComponent={MissingDownloadIconComponent} MissingDownloadIconComponent={() => (
DownloadedIconComponent={DownloadedIconComponent} <Ionicons name='download' size={20} color='white' />
)}
DownloadedIconComponent={() => (
<Ionicons name='download' size={20} color='#9333ea' />
)}
/> />
<PlayedStatus items={episodes || []} /> <PlayedStatus items={episodes || []} />
</View> </View>

View File

@@ -1,23 +1,3 @@
function renderHeaderLeft(hasDownloads: boolean, onPress: () => void) {
return (
<DownloadsHeaderButton hasDownloads={hasDownloads} onPress={onPress} />
);
}
// ...imports...
const DownloadsHeaderButton: React.FC<{
hasDownloads: boolean;
onPress: () => void;
}> = ({ hasDownloads, onPress }) => (
<TouchableOpacity onPress={onPress} className='p-2'>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
);
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk"; import type { Api } from "@jellyfin/sdk";
import type { import type {
@@ -120,8 +100,20 @@ export const HomeIndex = () => {
} }
const hasDownloads = getDownloadedItems().length > 0; const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({ navigation.setOptions({
headerLeft: () => headerLeft: () => (
renderHeaderLeft(hasDownloads, () => router.push("/(auth)/downloads")), <TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='p-2'
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
}); });
}, [navigation, router]); }, [navigation, router]);
@@ -131,7 +123,7 @@ export const HomeIndex = () => {
); );
}, []); }, []);
const segments = useSegments() as string[]; const segments = useSegments();
useEffect(() => { useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => { const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)") if ((segments as string[])[2] === "(home)")
@@ -326,10 +318,10 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return []; if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = []; const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) { for (const [index, section] of settings.home.sections.entries()) {
const id = `section-${index}`; const id = section.title || `section-${index}`;
ss.push({ ss.push({
title: t(`${id}`), title: t(`${id}`),
queryKey: ["home", id], queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async () => { queryFn: async () => {
if (section.items) { if (section.items) {
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -387,8 +379,8 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections; const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) { if (!isConnected || serverConnected !== true) {
let title: string = ""; let title = "";
let subtitle: string = ""; let subtitle = "";
if (!isConnected) { if (!isConnected) {
// No network connection // No network connection

View File

@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native"; import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { writeErrorLog } from "@/utils/log";
import { Button } from "../Button"; import { Button } from "../Button";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput"; import { PinInput } from "../inputs/PinInput";
@@ -65,8 +64,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
t("home.settings.quick_connect.invalid_code"), t("home.settings.quick_connect.invalid_code"),
); );
} }
} catch (error) { } catch (_e) {
writeErrorLog("quickConnect.authenticationError", error);
errorHapticFeedback(); errorHapticFeedback();
Alert.alert( Alert.alert(
t("home.settings.quick_connect.error"), t("home.settings.quick_connect.error"),
@@ -121,8 +119,6 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
)} )}
</Text> </Text>
<PinInput <PinInput
// Quick connect codes are typically 6 digits; ensure length prop provided
length={6}
value={quickConnectCode || ""} value={quickConnectCode || ""}
onChangeText={setQuickConnectCode} onChangeText={setQuickConnectCode}
style={{ paddingHorizontal: 16 }} style={{ paddingHorizontal: 16 }}

View File

@@ -7,6 +7,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { import {
type Dispatch, type Dispatch,
type FC, type FC,
type MutableRefObject,
type SetStateAction, type SetStateAction,
useCallback, useCallback,
useEffect, useEffect,
@@ -27,11 +28,10 @@ import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper"; import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay"; import { useTrickplay } from "@/hooks/useTrickplay";
import type { TrackInfo } from "@/modules/VlcPlayer.types"; import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
import { DownloadedItem } from "@/providers/Downloads/types"; import { DownloadedItem } from "@/providers/Downloads/types";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeDebugLog } from "@/utils/log";
import { ticksToMs } from "@/utils/time"; import { ticksToMs } from "@/utils/time";
import { BottomControls } from "./BottomControls"; import { BottomControls } from "./BottomControls";
import { CenterControls } from "./CenterControls"; import { CenterControls } from "./CenterControls";
@@ -50,6 +50,7 @@ import { type AspectRatio } from "./VideoScalingModeSelector";
interface Props { interface Props {
item: BaseItemDto; item: BaseItemDto;
videoRef: MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean; isPlaying: boolean;
isSeeking: SharedValue<boolean>; isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>; cacheProgress: SharedValue<number>;
@@ -57,6 +58,7 @@ interface Props {
isBuffering: boolean; isBuffering: boolean;
showControls: boolean; showControls: boolean;
enableTrickplay?: boolean;
togglePlay: () => void; togglePlay: () => void;
setShowControls: (shown: boolean) => void; setShowControls: (shown: boolean) => void;
offline?: boolean; offline?: boolean;
@@ -206,8 +208,8 @@ export const Controls: FC<Props> = ({
// Navigation hooks // Navigation hooks
const { const {
handleSeekBackward: asyncHandleSeekBackward, handleSeekBackward,
handleSeekForward: asyncHandleSeekForward, handleSeekForward,
handleSkipBackward, handleSkipBackward,
handleSkipForward, handleSkipForward,
} = useVideoNavigation({ } = useVideoNavigation({
@@ -218,21 +220,6 @@ export const Controls: FC<Props> = ({
play, play,
}); });
// Create sync wrappers for remote control
const handleSeekBackward = useCallback(
(seconds: number) => {
asyncHandleSeekBackward(seconds);
},
[asyncHandleSeekBackward],
);
const handleSeekForward = useCallback(
(seconds: number) => {
asyncHandleSeekForward(seconds);
},
[asyncHandleSeekForward],
);
// Time management hook // Time management hook
const { currentTime, remainingTime } = useVideoTime({ const { currentTime, remainingTime } = useVideoTime({
progress, progress,
@@ -390,7 +377,7 @@ export const Controls: FC<Props> = ({
item.UserData?.PlaybackPositionTicks?.toString() ?? "", item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString(); }).toString();
writeDebugLog("controls.navigate.queryParams", { queryParams }); console.log("queryParams", queryParams);
router.replace(`player/direct-player?${queryParams}` as any); router.replace(`player/direct-player?${queryParams}` as any);
}, },

View File

@@ -10,7 +10,6 @@ import {
useState, useState,
} from "react"; } from "react";
import type { TrackInfo } from "@/modules/VlcPlayer.types"; import type { TrackInfo } from "@/modules/VlcPlayer.types";
import { writeDebugLog } from "@/utils/log";
import type { Track } from "../types"; import type { Track } from "../types";
import { useControlContext } from "./ControlContext"; import { useControlContext } from "./ControlContext";
@@ -86,7 +85,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
chosenAudioIndex?: string; chosenAudioIndex?: string;
chosenSubtitleIndex?: string; chosenSubtitleIndex?: string;
}) => { }) => {
writeDebugLog("video.setPlayerParams", { chosenSubtitleIndex }); console.log("chosenSubtitleIndex", chosenSubtitleIndex);
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: itemId ?? "", itemId: itemId ?? "",
audioIndex: chosenAudioIndex, audioIndex: chosenAudioIndex,
@@ -115,7 +114,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
mediaSource?.TranscodingUrl && mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle; !onTextBasedSubtitle;
writeDebugLog("video.setTrackParams", { type, index, serverIndex }); console.log("Set player params", index, serverIndex);
if (shouldChangePlayerParams) { if (shouldChangePlayerParams) {
setPlayerParams({ setPlayerParams({
chosenSubtitleIndex: serverIndex.toString(), chosenSubtitleIndex: serverIndex.toString(),
@@ -128,57 +127,6 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}); });
}; };
// Extract subtitle track creation to reduce nesting
const createSubtitleTrack = (
sub: any,
subtitleData: TrackInfo[] | null,
embedSubIndex: { current: number },
): Track => {
const shouldIncrement =
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
const vlcIndex = subtitleData?.at(embedSubIndex.current)?.index ?? -1;
if (shouldIncrement) embedSubIndex.current++;
const handleSetTrack = () => {
if (shouldIncrement) {
setTrackParams("subtitle", vlcIndex, sub.Index ?? -1);
} else {
setPlayerParams({ chosenSubtitleIndex: sub.Index?.toString() });
}
};
return {
name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1,
setTrack: handleSetTrack,
};
};
// Extract audio track creation to reduce nesting
const createAudioTrack = (
audio: any,
idx: number,
audioData: TrackInfo[] | null,
): Track => {
const handleSetTrack = () => {
if (!mediaSource?.TranscodingUrl) {
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
setTrackParams("audio", vlcIndex, audio.Index ?? -1);
} else {
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() });
}
};
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: handleSetTrack,
};
};
useEffect(() => { useEffect(() => {
const fetchTracks = async () => { const fetchTracks = async () => {
if (getSubtitleTracks) { if (getSubtitleTracks) {
@@ -192,47 +140,73 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()]; subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
} }
const embedSubIndex = { current: 1 }; let embedSubIndex = 1;
const processedSubs: Track[] = allSubs?.map((sub) => const processedSubs: Track[] = allSubs?.map((sub) => {
createSubtitleTrack(sub, subtitleData, embedSubIndex), /** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
); const shouldIncrement =
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
/** The index of subtitle inside VLC Player Itself */
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
if (shouldIncrement) embedSubIndex++;
return {
name: sub.DisplayTitle || "Undefined Subtitle",
index: sub.Index ?? -1,
setTrack: () =>
shouldIncrement
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
: setPlayerParams({
chosenSubtitleIndex: sub.Index?.toString(),
}),
};
});
// Step 3: Restore the original order // Step 3: Restore the original order
const subtitles: Track[] = processedSubs.toSorted( const subtitles: Track[] = processedSubs.sort(
(a, b) => a.index - b.index, (a, b) => a.index - b.index,
); );
// Add a "Disable Subtitles" option // Add a "Disable Subtitles" option
const disableSubtitleTrack = { subtitles.unshift({
name: "Disable", name: "Disable",
index: -1, index: -1,
setTrack: () => { setTrack: () =>
if (!mediaSource?.TranscodingUrl || onTextBasedSubtitle) { !mediaSource?.TranscodingUrl || onTextBasedSubtitle
setTrackParams("subtitle", -1, -1); ? setTrackParams("subtitle", -1, -1)
} else { : setPlayerParams({ chosenSubtitleIndex: "-1" }),
setPlayerParams({ chosenSubtitleIndex: "-1" }); });
}
},
};
subtitles.unshift(disableSubtitleTrack);
setSubtitleTracks(subtitles); setSubtitleTracks(subtitles);
} }
if (getAudioTracks) { if (getAudioTracks) {
const audioData = await getAudioTracks(); const audioData = await getAudioTracks();
const allAudio = const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
const audioTracks: Track[] = allAudio?.map((audio, idx) => const audioTracks: Track[] = allAudio?.map((audio, idx) => {
createAudioTrack(audio, idx, audioData), if (!mediaSource?.TranscodingUrl) {
); const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setTrackParams("audio", vlcIndex, audio.Index ?? -1),
};
}
return {
name: audio.DisplayTitle ?? "Undefined Audio",
index: audio.Index ?? -1,
setTrack: () =>
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
};
});
// Add a "Disable Audio" option if its not transcoding. // Add a "Disable Audio" option if its not transcoding.
if (!mediaSource?.TranscodingUrl) { if (!mediaSource?.TranscodingUrl) {
const disableAudioTrack = { audioTracks.unshift({
name: "Disable", name: "Disable",
index: -1, index: -1,
setTrack: () => setTrackParams("audio", -1, -1), setTrack: () => setTrackParams("audio", -1, -1),
}; });
audioTracks.unshift(disableAudioTrack);
} }
setAudioTracks(audioTracks); setAudioTracks(audioTracks);
} }
@@ -240,25 +214,16 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
fetchTracks(); fetchTracks();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]); }, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
const contextValue = useMemo(
() => ({
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}),
[
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
],
);
return ( return (
<VideoContext.Provider value={contextValue}> <VideoContext.Provider
value={{
audioTracks,
subtitleTracks,
setSubtitleTrack,
setSubtitleURL,
setAudioTrack,
}}
>
{children} {children}
</VideoContext.Provider> </VideoContext.Provider>
); );

View File

@@ -140,8 +140,8 @@ const DropdownView = () => {
}} }}
className='flex flex-col rounded-xl overflow-hidden' className='flex flex-col rounded-xl overflow-hidden'
> >
{BITRATES?.map((bitrate) => ( {BITRATES?.map((bitrate, idx: number) => (
<View key={`quality-item-${bitrate.value}`}> <View key={`quality-item-${idx}`}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
changeBitrate(bitrate.value?.toString() ?? ""); changeBitrate(bitrate.value?.toString() ?? "");
@@ -164,8 +164,7 @@ const DropdownView = () => {
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
{BITRATES.findIndex((b) => b.value === bitrate.value) < {idx < BITRATES.length - 1 && (
BITRATES.length - 1 && (
<View <View
style={{ style={{
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
@@ -191,17 +190,17 @@ const DropdownView = () => {
}} }}
className='flex flex-col rounded-xl overflow-hidden' className='flex flex-col rounded-xl overflow-hidden'
> >
{subtitleTracks?.map((subtitle) => ( {subtitleTracks?.map((sub, idx: number) => (
<View key={`subtitle-item-${subtitle.index}`}> <View key={`subtitle-item-${idx}`}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
subtitle.setTrack(); sub.setTrack();
setTimeout(() => handleClose(), 250); setTimeout(() => handleClose(), 250);
}} }}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
> >
<Text className='flex shrink'>{subtitle.name}</Text> <Text className='flex shrink'>{sub.name}</Text>
{subtitleIndex === subtitle.index.toString() ? ( {subtitleIndex === sub.index.toString() ? (
<Ionicons <Ionicons
name='radio-button-on' name='radio-button-on'
size={24} size={24}
@@ -215,10 +214,7 @@ const DropdownView = () => {
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
{(subtitleTracks?.findIndex( {idx < (subtitleTracks?.length ?? 0) - 1 && (
(s) => s.index === subtitle.index,
) ?? 0) <
(subtitleTracks?.length ?? 0) - 1 && (
<View <View
style={{ style={{
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
@@ -244,17 +240,17 @@ const DropdownView = () => {
}} }}
className='flex flex-col rounded-xl overflow-hidden' className='flex flex-col rounded-xl overflow-hidden'
> >
{audioTracks?.map((audio) => ( {audioTracks?.map((track, idx: number) => (
<View key={`audio-item-${audio.index}`}> <View key={`audio-item-${idx}`}>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
audio.setTrack(); track.setTrack();
setTimeout(() => handleClose(), 250); setTimeout(() => handleClose(), 250);
}} }}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
> >
<Text className='flex shrink'>{audio.name}</Text> <Text className='flex shrink'>{track.name}</Text>
{audioIndex === audio.index.toString() ? ( {audioIndex === track.index.toString() ? (
<Ionicons <Ionicons
name='radio-button-on' name='radio-button-on'
size={24} size={24}
@@ -268,10 +264,7 @@ const DropdownView = () => {
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>
{(audioTracks?.findIndex( {idx < (audioTracks?.length ?? 0) - 1 && (
(a) => a.index === audio.index,
) ?? 0) <
(audioTracks?.length ?? 0) - 1 && (
<View <View
style={{ style={{
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,

View File

@@ -1,11 +1,10 @@
import axios, { type AxiosError, type AxiosInstance } from "axios"; import axios, { type AxiosError, type AxiosInstance } from "axios";
import { atom, useAtom } from "jotai"; import { atom } from "jotai";
import { useAtom } from "jotai/index";
import { inRange } from "lodash"; import { inRange } from "lodash";
import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User"; import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
import type { import type {
CollectionResult,
MovieResult, MovieResult,
PersonResult,
Results, Results,
TvResult, TvResult,
} from "@/utils/jellyseerr/server/models/Search"; } from "@/utils/jellyseerr/server/models/Search";
@@ -89,11 +88,11 @@ export enum Endpoints {
STUDIO = "/studio", STUDIO = "/studio",
GENRE_SLIDER = "/genreslider", GENRE_SLIDER = "/genreslider",
DISCOVER = "/discover", DISCOVER = "/discover",
DISCOVER_TRENDING = "/discover/trending", DISCOVER_TRENDING = `${DISCOVER}/trending`,
DISCOVER_MOVIES = "/discover/movies", DISCOVER_MOVIES = `${DISCOVER}/movies`,
DISCOVER_TV = "/discover/tv", DISCOVER_TV = DISCOVER + TV,
DISCOVER_TV_NETWORK = "/discover/tv/network", DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK,
DISCOVER_MOVIES_STUDIO = "/discover/movies/studio", DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`,
AUTH_JELLYFIN = "/auth/jellyfin", AUTH_JELLYFIN = "/auth/jellyfin",
} }
@@ -385,10 +384,10 @@ export class JellyseerrApi {
}, },
(error: AxiosError) => { (error: AxiosError) => {
writeErrorLog( writeErrorLog(
`Jellyseerr response error\nerror: ${String(error)}\nurl: ${error?.config?.url}`, `Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
error.response?.data, error.response?.data,
); );
if (error.status === 403) { if (error.response?.status === 403) {
clearJellyseerrStorageData(); clearJellyseerrStorageData();
} }
return Promise.reject(error); return Promise.reject(error);
@@ -469,77 +468,49 @@ export const useJellyseerr = () => {
); );
const isJellyseerrMovieOrTvResult = ( const isJellyseerrMovieOrTvResult = (
items: MediaItem | null | undefined, items: any | null | undefined,
): items is MovieResult | TvResult => { ): items is MovieResult | TvResult => {
return Boolean( return (
items && items &&
Object.hasOwn(items, "mediaType") && Object.hasOwn(items, "mediaType") &&
((items as MovieResult | TvResult).mediaType === MediaType.MOVIE || (items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
(items as MovieResult | TvResult).mediaType === MediaType.TV),
); );
}; };
type MediaItem = const getTitle = (
| TvResult item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
| TvDetails ) => {
| MovieResult return isJellyseerrMovieOrTvResult(item)
| MovieDetails ? item.mediaType === MediaType.MOVIE
| PersonCreditCast ? item?.title
| CollectionResult : item?.name
| PersonResult; : item?.mediaInfo?.mediaType === MediaType.MOVIE
? (item as MovieDetails)?.title
const getTitle = (item?: MediaItem) => { : (item as TvDetails)?.name;
if (isJellyseerrMovieOrTvResult(item)) {
return item.mediaType === MediaType.MOVIE ? item?.title : item?.name;
}
// Handle CollectionResult
if (item && "title" in item) {
return item.title;
}
if (item && "mediaInfo" in item) {
if (item.mediaInfo?.mediaType === MediaType.MOVIE) {
return (item as unknown as MovieDetails)?.title;
}
return (item as unknown as TvDetails)?.name;
}
return undefined;
}; };
const getYear = (item?: MediaItem) => { const getYear = (
let dateString = ""; item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
) => {
if (isJellyseerrMovieOrTvResult(item)) { return new Date(
dateString = (isJellyseerrMovieOrTvResult(item)
(item.mediaType === MediaType.MOVIE ? item.mediaType === MediaType.MOVIE
? item?.releaseDate ? item?.releaseDate
: item?.firstAirDate) || ""; : item?.firstAirDate
} else if (item && "mediaInfo" in item) { : item?.mediaInfo?.mediaType === MediaType.MOVIE
if (item.mediaInfo?.mediaType === MediaType.MOVIE) { ? (item as MovieDetails)?.releaseDate
dateString = (item as unknown as MovieDetails)?.releaseDate || ""; : (item as TvDetails)?.firstAirDate) || "",
} else { )?.getFullYear?.();
dateString = (item as unknown as TvDetails)?.firstAirDate || "";
}
}
return new Date(dateString)?.getFullYear?.();
}; };
const getMediaType = (item?: MediaItem): MediaType | undefined => { const getMediaType = (
if (isJellyseerrMovieOrTvResult(item)) { item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
return item.mediaType as MediaType; ): MediaType => {
} return isJellyseerrMovieOrTvResult(item)
? (item.mediaType as MediaType)
if (item && "mediaInfo" in item) { : item?.mediaInfo?.mediaType;
return item.mediaInfo?.mediaType;
}
return undefined;
}; };
// Adjusted to match current UserSettings field name (discoverRegion)
const jellyseerrRegion = useMemo( const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.discoverRegion || "US", () => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser], [jellyseerrUser],

View File

@@ -96,7 +96,9 @@ export const useWebSocket = ({
| Record<string, string> | Record<string, string>
| undefined; // Arguments are Dictionary<string, string> | undefined; // Arguments are Dictionary<string, string>
console.log("[WS] ~ ", lastMessage); // Sanitize output to avoid log injection
const msgStr = JSON.stringify(lastMessage).replaceAll(/[\n\r]/g, " ");
console.log("[WS] ~ %s", msgStr);
if (command === "PlayPause") { if (command === "PlayPause") {
console.log("Command ~ PlayPause"); console.log("Command ~ PlayPause");

View File

@@ -101,7 +101,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.5", "@babel/core": "7.28.5",
"@biomejs/biome": "2.2.6", "@biomejs/biome": "2.2.7",
"@react-native-community/cli": "20.0.2", "@react-native-community/cli": "20.0.2",
"@react-native-tvos/config-tv": "0.1.4", "@react-native-tvos/config-tv": "0.1.4",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",

View File

@@ -32,7 +32,6 @@ import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
import { Bitrate } from "../components/BitrateSelector"; import { Bitrate } from "../components/BitrateSelector";
import { import {
DownloadedItem, DownloadedItem,
DownloadedSeries,
DownloadsDatabase, DownloadsDatabase,
JobStatus, JobStatus,
TrickPlayData, TrickPlayData,
@@ -185,19 +184,11 @@ function useDownloadProvider() {
return; return;
} }
// check if processes are missing // check if processes are missing
const hasMetadataAndNotInProcesses = (task: any) => {
return task.metadata && !processes.some((p) => p.id === task.id);
};
const _findTaskById = (taskId: string) => {
return tasks.find((taskItem: any) => taskItem.id === taskId);
};
setProcesses((processes) => { setProcesses((processes) => {
const missingProcesses = tasks const missingProcesses = tasks
.filter(hasMetadataAndNotInProcesses) .filter((t: any) => t.metadata && !processes.some((p) => p.id === t.id))
.map((task: any) => { .map((t: any) => {
return task.metadata as JobStatus; return t.metadata as JobStatus;
}); });
const currentProcesses = [...processes, ...missingProcesses]; const currentProcesses = [...processes, ...missingProcesses];
@@ -293,7 +284,7 @@ function useDownloadProvider() {
}; };
} }
} }
return process; return p;
}); });
return updatedProcesses; return updatedProcesses;
@@ -677,7 +668,7 @@ function useDownloadProvider() {
// Handle other media types // Handle other media types
db.other[item.Id] = downloadedItem; db.other[item.Id] = downloadedItem;
} }
saveDownloadsDatabase(db); await saveDownloadsDatabase(db);
// Send native notification for successful download // Send native notification for successful download
const successNotification = getNotificationContent( const successNotification = getNotificationContent(
@@ -837,12 +828,12 @@ function useDownloadProvider() {
}); });
await saveImage(item.Id, itemImage?.uri); await saveImage(item.Id, itemImage?.uri);
const job: JobStatus = { const job: JobStatus = {
id: item.Id, id: item.Id!,
deviceId: deviceId, deviceId: deviceId,
maxBitrate, maxBitrate,
inputUrl: url, inputUrl: url,
item: item, item: item,
itemId: item.Id, itemId: item.Id!,
mediaSource, mediaSource,
progress: 0, progress: 0,
status: "queued", status: "queued",
@@ -874,61 +865,60 @@ function useDownloadProvider() {
[authHeader, startDownload], [authHeader, startDownload],
); );
const findAndDeleteEpisode = ( const deleteFile = async (id: string, type: BaseItemDto["Type"]) => {
db: DownloadsDatabase, const db = getDownloadsDatabase();
id: string, let downloadedItem: DownloadedItem | undefined;
): DownloadedItem | undefined => {
for (const [seriesId, series] of Object.entries(db.series)) { if (type === "Movie" && Object.entries(db.movies).length !== 0) {
for (const [seasonNumber, season] of Object.entries(series.seasons)) { downloadedItem = db.movies[id];
for (const [episodeNumber, episode] of Object.entries( if (downloadedItem) {
season.episodes, delete db.movies[id];
)) { }
if (episode.item.Id === id) { } else if (type === "Episode" && Object.entries(db.series).length !== 0) {
const downloadedItem = episode; const cleanUpEmptyParents = (
delete season.episodes[Number(episodeNumber)]; series: any,
cleanUpEmptyParents(series, Number(seasonNumber), seriesId, db); seasonNumber: string,
return downloadedItem; seriesId: string,
} ) => {
if (!Object.keys(series.seasons[seasonNumber].episodes).length) {
delete series.seasons[seasonNumber];
} }
if (!Object.keys(series.seasons).length) {
delete db.series[seriesId];
}
};
for (const [seriesId, series] of Object.entries(db.series)) {
for (const [seasonNumber, season] of Object.entries(series.seasons)) {
for (const [episodeNumber, episode] of Object.entries(
season.episodes,
)) {
if (episode.item.Id === id) {
downloadedItem = episode;
delete season.episodes[Number(episodeNumber)];
cleanUpEmptyParents(series, seasonNumber, seriesId);
break;
}
}
if (downloadedItem) break;
}
if (downloadedItem) break;
}
} else {
// Handle other media types
downloadedItem = db.other[id];
if (downloadedItem) {
delete db.other[id];
} }
} }
return undefined; if (downloadedItem?.videoFilePath) {
};
const cleanUpEmptyParents = (
series: DownloadedSeries,
seasonNumber: number,
seriesId: string,
db: DownloadsDatabase,
) => {
if (!Object.keys(series.seasons[seasonNumber].episodes).length) {
delete series.seasons[seasonNumber];
}
if (!Object.keys(series.seasons).length) {
delete db.series[seriesId];
}
};
const findAndDeleteMovie = (
db: DownloadsDatabase,
id: string,
): DownloadedItem | undefined => {
const downloadedItem = db.movies[id];
if (downloadedItem) {
delete db.movies[id];
}
return downloadedItem;
};
const deleteMediaFiles = async (downloadedItem: DownloadedItem) => {
if (downloadedItem.videoFilePath) {
await FileSystem.deleteAsync(downloadedItem.videoFilePath, { await FileSystem.deleteAsync(downloadedItem.videoFilePath, {
idempotent: true, idempotent: true,
}); });
} }
if (downloadedItem.mediaSource?.MediaStreams) { if (downloadedItem?.mediaSource?.MediaStreams) {
for (const stream of downloadedItem.mediaSource.MediaStreams) { for (const stream of downloadedItem.mediaSource.MediaStreams) {
if ( if (
stream.Type === "Subtitle" && stream.Type === "Subtitle" &&
@@ -941,32 +931,13 @@ function useDownloadProvider() {
} }
} }
if (downloadedItem.trickPlayData?.path) { if (downloadedItem?.trickPlayData?.path) {
await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, { await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, {
idempotent: true, idempotent: true,
}); });
} }
};
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => { await saveDownloadsDatabase(db);
const db = getDownloadsDatabase();
let downloadedItem: DownloadedItem | undefined;
if (type === "Movie") {
downloadedItem = findAndDeleteMovie(db, id);
} else if (type === "Episode") {
downloadedItem = findAndDeleteEpisode(db, id);
} else {
// Other media types
downloadedItem = db.other[id];
if (downloadedItem) delete db.other[id];
}
if (downloadedItem) {
await deleteMediaFiles(downloadedItem);
}
saveDownloadsDatabase(db);
successHapticFeedback(); successHapticFeedback();
}; };
@@ -1006,25 +977,6 @@ function useDownloadProvider() {
return downloadedItem.videoFileSize + trickplaySize; return downloadedItem.videoFileSize + trickplaySize;
}; };
/** Helper function to update episode in series database */
const updateEpisodeInSeries = (
db: DownloadsDatabase,
itemId: string,
updatedItem: DownloadedItem,
) => {
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === itemId) {
season.episodes[episode.item.IndexNumber as number] = updatedItem;
return true;
}
}
}
}
return false;
};
/** Updates a downloaded item. */ /** Updates a downloaded item. */
const updateDownloadedItem = ( const updateDownloadedItem = (
itemId: string, itemId: string,
@@ -1036,7 +988,15 @@ function useDownloadProvider() {
} else if (db.other[itemId]) { } else if (db.other[itemId]) {
db.other[itemId] = updatedItem; db.other[itemId] = updatedItem;
} else { } else {
updateEpisodeInSeries(db, itemId, updatedItem); for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === itemId) {
season.episodes[episode.item.IndexNumber as number] = updatedItem;
}
}
}
}
} }
saveDownloadsDatabase(db); saveDownloadsDatabase(db);
}; };
@@ -1321,9 +1281,7 @@ export function useDownload() {
return context; return context;
} }
export function DownloadProvider({ export function DownloadProvider({ children }: { children: React.ReactNode }) {
children,
}: Readonly<{ children: React.ReactNode }>) {
const downloadUtils = useDownloadProvider(); const downloadUtils = useDownloadProvider();
return ( return (
<DownloadContext.Provider value={downloadUtils}> <DownloadContext.Provider value={downloadUtils}>

View File

@@ -96,7 +96,9 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onmessage = (e) => { newWebSocket.onmessage = (e) => {
try { try {
const message = JSON.parse(e.data); const message = JSON.parse(e.data);
console.log("[WS] Received message:", message); // Sanitize output to avoid log injection
const msgStr = JSON.stringify(message).replaceAll(/[\n\r]/g, " ");
console.log("[WS] Received message: %s", msgStr);
setLastMessage(message); // Store the last message in context setLastMessage(message); // Store the last message in context
} catch (error) { } catch (error) {
console.error("Error parsing WebSocket message:", error); console.error("Error parsing WebSocket message:", error);

View File

@@ -333,7 +333,28 @@
"collections": "Collections", "collections": "Collections",
"actors": "Actors", "actors": "Actors",
"request_movies": "Request Movies", "request_movies": "Request Movies",
"request_series": "Request Series" "request_series": "Request Series",
"recently_added": "Recently Added",
"recent_requests": "Recent Requests",
"plex_watchlist": "Plex Watchlist",
"trending": "Trending",
"popular_movies": "Popular Movies",
"movie_genres": "Movie Genres",
"upcoming_movies": "Upcoming Movies",
"studios": "Studios",
"popular_tv": "Popular TV",
"tv_genres": "TV Genres",
"upcoming_tv": "Upcoming TV",
"networks": "Networks",
"tmdb_movie_keyword": "TMDB Movie Keyword",
"tmdb_movie_genre": "TMDB Movie Genre",
"tmdb_tv_keyword": "TMDB TV Keyword",
"tmdb_tv_genre": "TMDB TV Genre",
"tmdb_search": "TMDB Search",
"tmdb_studio": "TMDB Studio",
"tmdb_network": "TMDB Network",
"tmdb_movie_streaming_services": "TMDB Movie Streaming Services",
"tmdb_tv_streaming_services": "TMDB TV Streaming Services"
}, },
"library": { "library": {
"no_results": "No Results", "no_results": "No Results",

View File

@@ -88,6 +88,7 @@ export type Home = {
}; };
export type HomeSection = { export type HomeSection = {
title?: string;
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
items?: HomeSectionItemResolver; items?: HomeSectionItemResolver;
nextUp?: HomeSectionNextUpResolver; nextUp?: HomeSectionNextUpResolver;
@@ -134,7 +135,7 @@ export type HomeSectionLatestResolver = {
}; };
export enum VideoPlayer { export enum VideoPlayer {
// NATIVE, // Reserved for future native playback implementation // NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
VLC_3 = 0, VLC_3 = 0,
VLC_4 = 1, VLC_4 = 1,
} }

View File

@@ -4,7 +4,6 @@ import type {
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { Bitrate } from "@/components/BitrateSelector"; import { Bitrate } from "@/components/BitrateSelector";
import { writeDebugLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native"; import { generateDeviceProfile } from "@/utils/profiles/native";
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl"; import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
@@ -44,7 +43,7 @@ export const getDownloadUrl = async ({
}); });
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) { if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
writeDebugLog("download.directDownload", { itemId: item.Id }); console.log("Downloading item directly");
return { return {
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`, url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
mediaSource: streamDetails?.mediaSource ?? null, mediaSource: streamDetails?.mediaSource ?? null,

View File

@@ -5,7 +5,6 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { writeDebugLog } from "@/utils/log";
import download from "@/utils/profiles/download"; import download from "@/utils/profiles/download";
interface StreamResult { interface StreamResult {
@@ -46,7 +45,7 @@ const getPlaybackUrl = (
); );
} }
writeDebugLog("media.stream.transcoded", { transcodeUrl }); console.log("Video is being transcoded:", transcodeUrl);
return `${api.basePath}${transcodeUrl}`; return `${api.basePath}${transcodeUrl}`;
} }
@@ -71,7 +70,7 @@ const getPlaybackUrl = (
const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`; const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`;
writeDebugLog("media.stream.directPlay", { directPlayUrl }); console.log("Video is being direct played:", directPlayUrl);
return directPlayUrl; return directPlayUrl;
}; };
@@ -165,11 +164,7 @@ export const getStreamUrl = async ({
mediaSource: MediaSourceInfo | undefined; mediaSource: MediaSourceInfo | undefined;
} | null> => { } | null> => {
if (!api || !userId || !item?.Id) { if (!api || !userId || !item?.Id) {
writeDebugLog("media.stream.missingParams", { console.warn("Missing required parameters for getStreamUrl");
hasApi: !!api,
hasUserId: !!userId,
hasItemId: !!item?.Id,
});
return null; return null;
} }
@@ -178,7 +173,7 @@ export const getStreamUrl = async ({
// Please do not remove this we need this for live TV to be working correctly. // Please do not remove this we need this for live TV to be working correctly.
if (item.Type === BaseItemKind.Program) { if (item.Type === BaseItemKind.Program) {
writeDebugLog("media.stream.programDetected", { itemId: item.Id }); console.log("Item is of type program...");
const res = await getMediaInfoApi(api).getPlaybackInfo( const res = await getMediaInfoApi(api).getPlaybackInfo(
{ {
userId, userId,
@@ -238,10 +233,7 @@ export const getStreamUrl = async ({
); );
if (res.status !== 200) { if (res.status !== 200) {
writeDebugLog("media.stream.playbackInfoError", { console.error("Error getting playback info:", res.status, res.statusText);
status: res.status,
statusText: res.statusText,
});
} }
sessionId = res.data.PlaySessionId || null; sessionId = res.data.PlaySessionId || null;
@@ -288,11 +280,7 @@ export const getDownloadStreamUrl = async ({
mediaSource: MediaSourceInfo | undefined; mediaSource: MediaSourceInfo | undefined;
} | null> => { } | null> => {
if (!api || !userId || !item?.Id) { if (!api || !userId || !item?.Id) {
writeDebugLog("media.downloadStream.missingParams", { console.warn("Missing required parameters for getStreamUrl");
hasApi: !!api,
hasUserId: !!userId,
hasItemId: !!item?.Id,
});
return null; return null;
} }
@@ -317,10 +305,7 @@ export const getDownloadStreamUrl = async ({
); );
if (res.status !== 200) { if (res.status !== 200) {
writeDebugLog("media.downloadStream.playbackInfoError", { console.error("Error getting playback info:", res.status, res.statusText);
status: res.status,
statusText: res.statusText,
});
} }
const sessionId = res.data.PlaySessionId || null; const sessionId = res.data.PlaySessionId || null;

View File

@@ -1,19 +0,0 @@
// Centralized helpers for navigation-related logic to reduce duplication and Sonar code smells.
// Navigation tab constants to eliminate string literal duplication
export const TAB_ROUTES = {
HOME: "(home)",
SEARCH: "(search)",
LIBRARIES: "(libraries)",
FAVORITES: "(favorites)",
} as const;
/**
* Derive current tab/root segment from expo-router segments array.
* Falls back gracefully to the last available segment or HOME.
*/
export function getCurrentTab(segments: readonly string[]): string {
if (!segments || segments.length === 0) return TAB_ROUTES.HOME;
if (segments.length > 2) return segments[2] || TAB_ROUTES.HOME;
return segments[segments.length - 1] || TAB_ROUTES.HOME;
}