mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-16 16:18:09 +00:00
Compare commits
21 Commits
codeql-fix
...
sonarqube
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5295186b0 | ||
|
|
3df16a2be1 | ||
|
|
c831858405 | ||
|
|
d08e92d7ca | ||
|
|
db84c822fe | ||
|
|
2f9e9b82e5 | ||
|
|
ae0574433b | ||
|
|
638dda3fa6 | ||
|
|
a5962e63aa | ||
|
|
1749de118f | ||
|
|
27d9098de8 | ||
|
|
6a187e38f7 | ||
|
|
8dc3984907 | ||
|
|
e4efe58b28 | ||
|
|
f236fead73 | ||
|
|
184f639920 | ||
|
|
f16baeb226 | ||
|
|
95297781eb | ||
|
|
fa8bd57561 | ||
|
|
ee98917276 | ||
|
|
64c2a78bc6 |
27
.gitignore
vendored
27
.gitignore
vendored
@@ -1,7 +1,34 @@
|
|||||||
# 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
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -26,6 +26,13 @@
|
|||||||
"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
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, 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,7 +23,26 @@ 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";
|
||||||
|
|
||||||
export default function page() {
|
interface HeaderRightProps {
|
||||||
|
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);
|
||||||
@@ -36,6 +55,17 @@ export default function page() {
|
|||||||
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 = () => {
|
||||||
@@ -53,9 +83,13 @@ export default function page() {
|
|||||||
{
|
{
|
||||||
text: t("home.downloads.delete"),
|
text: t("home.downloads.delete"),
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: () => {
|
||||||
await deleteAllFiles();
|
deleteAllFiles()
|
||||||
setShowMigration(false);
|
.then(() => setShowMigration(false))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to delete all files:", error);
|
||||||
|
setShowMigration(false);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -103,15 +137,21 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
}, [downloadedFiles]);
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const headerRightComponent = useMemo(
|
||||||
|
() => (
|
||||||
|
<HeaderRight
|
||||||
|
downloadedFiles={downloadedFiles}
|
||||||
|
onPress={() => bottomSheetModalRef.current?.present()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[downloadedFiles],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => headerRightComponent,
|
||||||
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
|
||||||
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}, [downloadedFiles]);
|
}, [headerRightComponent, navigation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showMigration) {
|
if (showMigration) {
|
||||||
@@ -180,13 +220,13 @@ export default function page() {
|
|||||||
{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, index) => (
|
{queue.map((q) => (
|
||||||
<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={index}
|
key={q.id}
|
||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className='font-semibold'>{q.item.Name}</Text>
|
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||||
@@ -195,13 +235,7 @@ export default function page() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => handleRemoveQueueItem(q.id)}
|
||||||
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>
|
||||||
@@ -320,13 +354,7 @@ export default function page() {
|
|||||||
backgroundStyle={{
|
backgroundStyle={{
|
||||||
backgroundColor: "#171717",
|
backgroundColor: "#171717",
|
||||||
}}
|
}}
|
||||||
backdropComponent={(props: BottomSheetBackdropProps) => (
|
backdropComponent={CustomBottomSheetBackdrop}
|
||||||
<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'>
|
||||||
@@ -349,4 +377,6 @@ export default function page() {
|
|||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default DownloadsPage;
|
||||||
|
|||||||
@@ -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 } from "react";
|
import { useEffect, useMemo } 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,7 +25,19 @@ 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";
|
||||||
|
|
||||||
export default function settings() {
|
interface LogoutButtonProps {
|
||||||
|
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);
|
||||||
@@ -37,22 +49,17 @@ export default function settings() {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerRightComponent = useMemo(
|
||||||
|
() => <LogoutButton onPress={() => logout()} />,
|
||||||
|
[logout],
|
||||||
|
);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => (
|
headerRight: () => headerRightComponent,
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
logout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className='text-red-600'>
|
|
||||||
{t("home.settings.log_out_button")}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, [headerRightComponent, navigation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -118,4 +125,6 @@ export default function settings() {
|
|||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
|
|||||||
@@ -1,3 +1,18 @@
|
|||||||
|
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,
|
||||||
@@ -42,7 +57,6 @@ 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();
|
||||||
@@ -64,7 +78,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);
|
||||||
|
|
||||||
@@ -115,18 +129,18 @@ const Page: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
const setRequestBody = useCallback(
|
const handleSetRequestBody = 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)
|
||||||
@@ -134,20 +148,12 @@ const Page: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (hasAdvancedRequestPermission) {
|
if (hasAdvancedRequestPermission) {
|
||||||
setRequestBody(body);
|
handleSetRequestBody(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(
|
||||||
() =>
|
() =>
|
||||||
@@ -156,17 +162,81 @@ const Page: React.FC = () => {
|
|||||||
[details],
|
[details],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const headerRightComponent = useMemo(
|
||||||
|
() => <HeaderRight details={details as MovieDetails | TvDetails | null} />,
|
||||||
|
[details],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (details) {
|
navigation.setOptions({
|
||||||
navigation.setOptions({
|
headerRight: () => headerRightComponent,
|
||||||
headerRight: () => (
|
});
|
||||||
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
}, [headerRightComponent, navigation]);
|
||||||
<ItemActions item={details} />
|
|
||||||
</TouchableOpacity>
|
const renderActionButton = () => {
|
||||||
),
|
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
|
||||||
@@ -318,7 +388,7 @@ const Page: React.FC = () => {
|
|||||||
details={details as TvDetails}
|
details={details as TvDetails}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
hasAdvancedRequest={hasAdvancedRequestPermission}
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
onAdvancedRequest={(data) => setRequestBody(data)}
|
onAdvancedRequest={(data) => handleSetRequestBody(data)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DetailFacts
|
<DetailFacts
|
||||||
@@ -337,11 +407,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
|
||||||
|
|||||||
@@ -19,9 +19,49 @@ 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";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
function MissingDownloadIcon() {
|
||||||
const navigation = useNavigation();
|
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();
|
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 params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,36 +125,22 @@ const page: 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: () =>
|
headerRight: () => headerRightComponent,
|
||||||
!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>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}, [allEpisodes, isLoading, item]);
|
}, [headerRightComponent, navigation]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
@@ -158,4 +184,4 @@ const page: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default page;
|
export default SeriesPage;
|
||||||
|
|||||||
@@ -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,10 +81,13 @@ export default function page() {
|
|||||||
|
|
||||||
const lightHapticFeedback = useHaptic("light");
|
const lightHapticFeedback = useHaptic("light");
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
const setShowControlsWithHaptic = useCallback(
|
||||||
_setShowControls(show);
|
(show: boolean) => {
|
||||||
lightHapticFeedback();
|
setShowControls(show);
|
||||||
}, []);
|
lightHapticFeedback();
|
||||||
|
},
|
||||||
|
[lightHapticFeedback, setShowControls],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
itemId,
|
itemId,
|
||||||
@@ -144,7 +147,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 as BaseItemDto;
|
fetchedItem = data.item;
|
||||||
setDownloadedItem(data);
|
setDownloadedItem(data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -179,64 +182,82 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: Stream | null = null;
|
const result = offline
|
||||||
if (offline && downloadedItem && downloadedItem.mediaSource) {
|
? createOfflineStream()
|
||||||
const url = downloadedItem.videoFilePath;
|
: await fetchOnlineStream();
|
||||||
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) {
|
||||||
@@ -253,6 +274,9 @@ export default function page() {
|
|||||||
item,
|
item,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
|
offline,
|
||||||
|
createOfflineStream,
|
||||||
|
fetchOnlineStream,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -321,8 +345,8 @@ export default function page() {
|
|||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
return {
|
return {
|
||||||
itemId: item?.Id!,
|
itemId: item?.Id!,
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
audioStreamIndex: audioIndex || undefined,
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
subtitleStreamIndex: subtitleIndex || undefined,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
positionTicks: msToTicks(progress.get()),
|
positionTicks: msToTicks(progress.get()),
|
||||||
isPaused: !isPlaying,
|
isPaused: !isPlaying,
|
||||||
@@ -478,15 +502,46 @@ 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: togglePlay,
|
togglePlay: handleTogglePlay,
|
||||||
stopPlayback: stop,
|
stopPlayback: stop,
|
||||||
offline,
|
offline,
|
||||||
toggleMute: toggleMuteCb,
|
toggleMute: handleToggleMute,
|
||||||
volumeUp: volumeUpCb,
|
volumeUp: handleVolumeUp,
|
||||||
volumeDown: volumeDownCb,
|
volumeDown: handleVolumeDown,
|
||||||
setVolume: setVolumeCb,
|
setVolume: handleSetVolume,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onPlaybackStateChanged = useCallback(
|
const onPlaybackStateChanged = useCallback(
|
||||||
@@ -733,7 +788,6 @@ 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}
|
||||||
@@ -741,13 +795,12 @@ export default function page() {
|
|||||||
cacheProgress={cacheProgress}
|
cacheProgress={cacheProgress}
|
||||||
isBuffering={isBuffering}
|
isBuffering={isBuffering}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControlsWithHaptic}
|
||||||
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}
|
||||||
|
|||||||
@@ -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 }: PropsWithChildren) {
|
export default function Root({ children }: Readonly<PropsWithChildren>) {
|
||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang='en'>
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -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 { Platform } from "react-native";
|
import { Appearance, AppState, 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,6 +9,7 @@ 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";
|
||||||
@@ -45,10 +46,9 @@ 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 } from "jotai";
|
import { Provider as JotaiProvider, useAtom } 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,9 +60,7 @@ 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) {
|
||||||
@@ -281,7 +279,7 @@ function Layout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
if (!Platform.isTV && user?.Policy?.IsAdministrator) {
|
||||||
await registerBackgroundFetchAsyncSessions();
|
await registerBackgroundFetchAsyncSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +316,7 @@ function Layout() {
|
|||||||
writeInfoLog(`Notification ${title} opened`, data);
|
writeInfoLog(`Notification ${title} opened`, data);
|
||||||
|
|
||||||
let url: any;
|
let url: any;
|
||||||
const type = (data?.type ?? "").toString().toLowerCase();
|
const type = String(data?.type ?? "").toLowerCase();
|
||||||
const itemId = data?.id;
|
const itemId = data?.id;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -329,13 +327,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=${itemId}`;
|
url = `/(auth)/(tabs)/home/items/page?id=${String(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/${seriesId}?seasonIndex=${seasonIndex}`;
|
url = `/(auth)/(tabs)/home/series/${String(seriesId)}?seasonIndex=${String(seasonIndex)}`;
|
||||||
} else {
|
} else {
|
||||||
url = `/(auth)/(tabs)/home/series/${seriesId}`;
|
url = `/(auth)/(tabs)/home/series/${seriesId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 any);
|
router.push(navigation as `/(auth)/(tabs)/${string}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
|
|
||||||
const colorClasses = useMemo(() => {
|
const getColorClasses = (color: string, focused: boolean) => {
|
||||||
switch (color) {
|
switch (color) {
|
||||||
case "purple":
|
case "purple":
|
||||||
return focused
|
return focused
|
||||||
@@ -68,12 +68,38 @@ 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");
|
||||||
|
|
||||||
return Platform.isTV ? (
|
const handlePress = () => {
|
||||||
|
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}
|
||||||
@@ -93,7 +119,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, // Android glow
|
elevation: focused ? 12 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
@@ -105,7 +131,9 @@ 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
|
||||||
@@ -113,12 +141,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
${colorClasses}
|
${colorClasses}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
onPress={() => {
|
onPress={handlePress}
|
||||||
if (!loading && !disabled && onPress) {
|
|
||||||
onPress();
|
|
||||||
lightHapticFeedback();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -128,25 +151,15 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`flex flex-row items-center justify-between w-full ${getJustifyClass()}`}
|
||||||
flex flex-row items-center justify-between w-full
|
|
||||||
${justify === "between" ? "justify-between" : "justify-center"}`}
|
|
||||||
>
|
>
|
||||||
{iconLeft ? iconLeft : <View className='w-4' />}
|
{iconLeft || <View className='w-4' />}
|
||||||
<Text
|
<Text className={getTextClasses()}>{children}</Text>
|
||||||
className={`
|
{iconRight || <View className='w-4' />}
|
||||||
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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ 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 CastContext, {
|
import {
|
||||||
CastButton,
|
CastButton,
|
||||||
|
CastContext,
|
||||||
PlayServicesState,
|
PlayServicesState,
|
||||||
useMediaStatus,
|
useMediaStatus,
|
||||||
useRemoteMediaClient,
|
useRemoteMediaClient,
|
||||||
@@ -46,6 +47,63 @@ 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,
|
||||||
@@ -86,12 +144,86 @@ 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() ?? "",
|
||||||
@@ -101,14 +233,11 @@ 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(
|
||||||
@@ -121,137 +250,23 @@ 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 {
|
||||||
// Check if user wants H265 for Chromecast
|
await handleChromecast({
|
||||||
const enableH265 = settings.enableH265ForChromecast;
|
item,
|
||||||
|
api,
|
||||||
// Validate required parameters before calling getStreamUrl
|
user,
|
||||||
if (!api) {
|
selectedOptions,
|
||||||
console.warn("API not available for Chromecast streaming");
|
client,
|
||||||
Alert.alert(
|
t,
|
||||||
t("player.client_error"),
|
settings,
|
||||||
t("player.missing_parameters"),
|
isOpeningCurrentlyPlayingMedia:
|
||||||
);
|
!!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;
|
||||||
@@ -273,10 +288,14 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
showActionSheetWithOptions,
|
showActionSheetWithOptions,
|
||||||
mediaStatus,
|
mediaStatus,
|
||||||
selectedOptions,
|
selectedOptions,
|
||||||
|
lightHapticFeedback,
|
||||||
|
goToPlayer,
|
||||||
|
isOffline,
|
||||||
|
handleChromecast,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const derivedTargetWidth = useDerivedValue(() => {
|
const derivedTargetWidth = useDerivedValue(() => {
|
||||||
if (!item || !item.RunTimeTicks) return 0;
|
if (!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
|
||||||
|
|||||||
@@ -76,11 +76,10 @@ 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 || !item.RunTimeTicks) return 0;
|
if (!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
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function InfiniteHorizontalScroll({
|
|||||||
loading = false,
|
loading = false,
|
||||||
height = 164,
|
height = 164,
|
||||||
...props
|
...props
|
||||||
}: HorizontalScrollProps): React.ReactElement {
|
}: Readonly<HorizontalScrollProps>): React.ReactElement {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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;
|
||||||
@@ -40,7 +41,8 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
const from = (segments as string[])[2] || "(home)";
|
// Some segment arrays may have fewer than 3 elements; fall back to home tab.
|
||||||
|
const from = getCurrentTab(segments as string[]);
|
||||||
|
|
||||||
const autoApprove = useMemo(() => {
|
const autoApprove = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -69,14 +71,13 @@ 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: releaseYear.toString(),
|
||||||
canRequest: canRequest.toString(),
|
canRequest: canRequest ? "true" : "false",
|
||||||
posterSrc,
|
posterSrc,
|
||||||
mediaType,
|
mediaType: mediaType.toString(),
|
||||||
|
id: result.id?.toString?.() ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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: TextProps) {
|
export function Text(props: Readonly<TextProps>) {
|
||||||
const { style, ...otherProps } = props;
|
const { style, ...otherProps } = props;
|
||||||
if (Platform.isTV)
|
if (Platform.isTV)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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;
|
||||||
@@ -43,47 +44,8 @@ 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 const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
// Export alias for backward compatibility
|
||||||
if ("CollectionType" in item && item.CollectionType === "livetv") {
|
export const getItemNavigation = itemRouter;
|
||||||
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,
|
||||||
@@ -97,7 +59,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 = (segments as string[])[2] || "(home)";
|
const from = getCurrentTab(segments as string[]);
|
||||||
|
|
||||||
const showActionSheet = useCallback(() => {
|
const showActionSheet = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -143,15 +105,11 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onLongPress={showActionSheet}
|
onLongPress={showActionSheet}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
let url = itemRouter(item, from);
|
||||||
if (isOffline) {
|
if (isOffline) {
|
||||||
// For offline mode, we still need to use query params
|
url += `&offline=true`;
|
||||||
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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ 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 { getItemNavigation } from "../common/TouchableItemRouter";
|
import { getCurrentTab } from "@/utils/navigation";
|
||||||
|
import { itemRouter } from "../common/TouchableItemRouter";
|
||||||
|
|
||||||
interface Props extends ViewProps {}
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
@@ -147,16 +148,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();
|
const segments = useSegments() as string[];
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = getCurrentTab(segments);
|
||||||
|
|
||||||
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();
|
||||||
const navigation = getItemNavigation(item, from);
|
if (url) router.push(url as any);
|
||||||
router.push(navigation as any);
|
|
||||||
}, [item, from]);
|
}, [item, from]);
|
||||||
|
|
||||||
const tap = Gesture.Tap()
|
const tap = Gesture.Tap()
|
||||||
|
|||||||
@@ -4,18 +4,16 @@ 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 {
|
||||||
extends Omit<TextInputProps, "value" | "onChangeText" | "style"> {
|
readonly length: number;
|
||||||
value: string;
|
readonly value: string;
|
||||||
onChangeText: (text: string) => void;
|
readonly onChangeText: (text: string) => void;
|
||||||
length?: number;
|
readonly style?: StyleProp<ViewStyle>;
|
||||||
autoFocus?: boolean;
|
readonly autoFocus?: boolean;
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PinInputRef {
|
export interface PinInputRef {
|
||||||
@@ -65,7 +63,7 @@ const PinInputComponent = React.forwardRef<PinInputRef, PinInputProps>(
|
|||||||
.fill(0)
|
.fill(0)
|
||||||
.map((_, i) => (
|
.map((_, i) => (
|
||||||
<View
|
<View
|
||||||
key={i}
|
key={`pin-input-cell-${i}-${length}`}
|
||||||
style={[
|
style={[
|
||||||
styles.cell,
|
styles.cell,
|
||||||
i === activeIndex && styles.activeCell,
|
i === activeIndex && styles.activeCell,
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const BACKDROP_DURATION = 5000;
|
|||||||
|
|
||||||
type Render = React.ComponentType<any> | React.ReactElement | null | undefined;
|
type Render = React.ComponentType<any> | React.ReactElement | null | undefined;
|
||||||
|
|
||||||
interface Props<T> {
|
const ItemSeparator = () => <View className='h-2 w-2' />;
|
||||||
|
|
||||||
|
interface ParallaxSlideShowProps<T> {
|
||||||
data: T[];
|
data: T[];
|
||||||
images: string[];
|
images: string[];
|
||||||
logo?: React.ReactElement;
|
logo?: React.ReactElement;
|
||||||
@@ -27,7 +29,7 @@ interface Props<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 | undefined;
|
onEndReached?: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ParallaxSlideShow = <T,>({
|
const ParallaxSlideShow = <T,>({
|
||||||
@@ -40,7 +42,7 @@ const ParallaxSlideShow = <T,>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
keyExtractor,
|
keyExtractor,
|
||||||
onEndReached,
|
onEndReached,
|
||||||
}: PropsWithChildren<Props<T> & ViewProps>) => {
|
}: PropsWithChildren<ParallaxSlideShowProps<T> & ViewProps>) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
@@ -66,15 +68,21 @@ 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(() => {
|
||||||
Animated.sequence([enterAnimation(), exitAnimation()]).start(() => {
|
createSlideSequence().start(handleAnimationComplete);
|
||||||
fadeAnim.setValue(0);
|
|
||||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
|
|
||||||
});
|
|
||||||
}, BACKDROP_DURATION);
|
}, BACKDROP_DURATION);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -88,6 +96,8 @@ const ParallaxSlideShow = <T,>({
|
|||||||
exitAnimation,
|
exitAnimation,
|
||||||
setCurrentIndex,
|
setCurrentIndex,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
|
createSlideSequence,
|
||||||
|
handleAnimationComplete,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -139,12 +149,20 @@ const ParallaxSlideShow = <T,>({
|
|||||||
}
|
}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
//@ts-expect-error
|
renderItem={({ item, index }) => {
|
||||||
renderItem={({ item, index }) => renderItem(item, index)}
|
const rendered = renderItem(item as any, 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={() => <View className='h-2 w-2' />}
|
ItemSeparatorComponent={ItemSeparator}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
@@ -21,8 +22,8 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments() as string[];
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = getCurrentTab(segments);
|
||||||
|
|
||||||
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
if (from === "(home)" || from === "(search)" || from === "(libraries)")
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ 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();
|
const segments = useSegments() as string[];
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = getCurrentTab(segments);
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
({ id, image, name }: Network | Studio) =>
|
({ id, image, name }: Network | Studio) =>
|
||||||
|
|||||||
@@ -34,37 +34,34 @@ const GenericSlideCard: React.FC<
|
|||||||
contentFit = "contain",
|
contentFit = "contain",
|
||||||
...props
|
...props
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<LinearGradient
|
||||||
<LinearGradient
|
colors={colors}
|
||||||
colors={colors}
|
start={{ x: 0.5, y: 1.75 }}
|
||||||
start={{ x: 0.5, y: 1.75 }}
|
end={{ x: 0.5, y: 0 }}
|
||||||
end={{ x: 0.5, y: 0 }}
|
className='rounded-xl'
|
||||||
className='rounded-xl'
|
>
|
||||||
>
|
<View className='rounded-xl' {...props}>
|
||||||
<View className='rounded-xl' {...props}>
|
<Image
|
||||||
<Image
|
key={id}
|
||||||
key={id}
|
id={id}
|
||||||
id={id}
|
source={url ? { uri: url } : null}
|
||||||
source={url ? { uri: url } : null}
|
cachePolicy={"memory-disk"}
|
||||||
cachePolicy={"memory-disk"}
|
contentFit={contentFit}
|
||||||
contentFit={contentFit}
|
style={{
|
||||||
style={{
|
aspectRatio: "4/3",
|
||||||
aspectRatio: "4/3",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
{title && (
|
||||||
{title && (
|
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
|
||||||
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
|
<Text
|
||||||
<Text
|
className='text-center font-bold'
|
||||||
className='text-center font-bold'
|
style={textShadowStyle.shadow}
|
||||||
style={textShadowStyle.shadow}
|
>
|
||||||
>
|
{title}
|
||||||
{title}
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</View>
|
||||||
</View>
|
</LinearGradient>
|
||||||
</LinearGradient>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export default GenericSlideCard;
|
export default GenericSlideCard;
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ 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();
|
const segments = useSegments() as string[];
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
const from = (segments as string[])[2] || "(home)";
|
const from = getCurrentTab(segments);
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(genre: GenreSliderItem) =>
|
(genre: GenreSliderItem) =>
|
||||||
|
|||||||
@@ -6,16 +6,8 @@ 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";
|
|
||||||
|
|
||||||
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
|
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
|
||||||
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({
|
||||||
@@ -74,17 +66,9 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
|
|||||||
<Slide
|
<Slide
|
||||||
{...props}
|
{...props}
|
||||||
slide={slide}
|
slide={slide}
|
||||||
data={
|
data={requests.results as MediaRequest[]}
|
||||||
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: ExtendedMediaRequest) => (
|
renderItem={(item: MediaRequest) => <RequestCard request={item} />}
|
||||||
<RequestCard request={item} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 | undefined;
|
onEndReached?: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,10 +49,16 @@ const Slide = <T,>({
|
|||||||
data={data}
|
data={data}
|
||||||
onEndReachedThreshold={1}
|
onEndReachedThreshold={1}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
//@ts-expect-error
|
renderItem={({ item, index }) => {
|
||||||
renderItem={({ item, index }) =>
|
if (!item) return null;
|
||||||
item ? renderItem(item, index) : null
|
const rendered = renderItem(item, index);
|
||||||
}
|
if (!rendered) return null;
|
||||||
|
if (typeof rendered === "function") {
|
||||||
|
const Comp: any = rendered;
|
||||||
|
return <Comp />;
|
||||||
|
}
|
||||||
|
return rendered;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ 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 { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
import {
|
||||||
|
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";
|
||||||
@@ -123,11 +126,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={mediaType || MediaType.MOVIE}
|
||||||
>
|
>
|
||||||
<View className={"flex flex-col mr-2 h-auto"}>
|
<View className={"flex flex-col mr-2 h-auto"}>
|
||||||
<View
|
<View
|
||||||
@@ -191,7 +194,7 @@ const JellyseerrPoster: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
<JellyseerrMediaIcon
|
<JellyseerrMediaIcon
|
||||||
className='absolute top-1 left-1'
|
className='absolute top-1 left-1'
|
||||||
mediaType={mediaType}
|
mediaType={mediaType as "movie" | "tv"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ 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 {
|
||||||
@@ -21,9 +23,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();
|
const segments = useSegments() as string[];
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const from = (segments as string[])[2];
|
const from = getCurrentTab(segments);
|
||||||
|
|
||||||
const destinctPeople = useMemo(() => {
|
const destinctPeople = useMemo(() => {
|
||||||
const people: Record<string, BaseItemPerson> = {};
|
const people: Record<string, BaseItemPerson> = {};
|
||||||
@@ -55,12 +57,14 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
|
|||||||
renderItem={(i) => (
|
renderItem={(i) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (i.Id) {
|
const url = itemRouter(
|
||||||
router.push({
|
{
|
||||||
pathname: "/persons/[personId]",
|
Id: i.Id,
|
||||||
params: { personId: i.Id },
|
Type: "Person",
|
||||||
});
|
},
|
||||||
}
|
from,
|
||||||
|
);
|
||||||
|
router.push(url as any);
|
||||||
}}
|
}}
|
||||||
className='flex flex-col w-28'
|
className='flex flex-col w-28'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
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 } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } 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";
|
||||||
@@ -70,7 +77,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({
|
||||||
@@ -100,6 +107,8 @@ 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(() => {
|
||||||
@@ -133,12 +142,8 @@ 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}
|
||||||
<Ionicons name='download' size={20} color='white' />
|
DownloadedIconComponent={DownloadedIconComponent}
|
||||||
)}
|
|
||||||
DownloadedIconComponent={() => (
|
|
||||||
<Ionicons name='download' size={20} color='#9333ea' />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<PlayedStatus items={episodes || []} />
|
<PlayedStatus items={episodes || []} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
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 {
|
||||||
@@ -100,20 +120,8 @@ export const HomeIndex = () => {
|
|||||||
}
|
}
|
||||||
const hasDownloads = getDownloadedItems().length > 0;
|
const hasDownloads = getDownloadedItems().length > 0;
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerLeft: () => (
|
headerLeft: () =>
|
||||||
<TouchableOpacity
|
renderHeaderLeft(hasDownloads, () => router.push("/(auth)/downloads")),
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
className='p-2'
|
|
||||||
>
|
|
||||||
<Feather
|
|
||||||
name='download'
|
|
||||||
color={hasDownloads ? Colors.primary : "white"}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}, [navigation, router]);
|
}, [navigation, router]);
|
||||||
|
|
||||||
@@ -123,7 +131,7 @@ export const HomeIndex = () => {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const segments = useSegments();
|
const segments = useSegments() as string[];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.on("scrollToTop", () => {
|
const unsubscribe = eventBus.on("scrollToTop", () => {
|
||||||
if ((segments as string[])[2] === "(home)")
|
if ((segments as string[])[2] === "(home)")
|
||||||
@@ -318,10 +326,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.title || `section-${index}`;
|
const id = `section-${index}`;
|
||||||
ss.push({
|
ss.push({
|
||||||
title: t(`${id}`),
|
title: t(`${id}`),
|
||||||
queryKey: ["home", "custom", String(index), section.title ?? null],
|
queryKey: ["home", id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (section.items) {
|
if (section.items) {
|
||||||
const response = await getItemsApi(api).getItems({
|
const response = await getItemsApi(api).getItems({
|
||||||
@@ -379,8 +387,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 = "";
|
let title: string = "";
|
||||||
let subtitle = "";
|
let subtitle: string = "";
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// No network connection
|
// No network connection
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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";
|
||||||
@@ -64,7 +65,8 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
t("home.settings.quick_connect.invalid_code"),
|
t("home.settings.quick_connect.invalid_code"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (error) {
|
||||||
|
writeErrorLog("quickConnect.authenticationError", error);
|
||||||
errorHapticFeedback();
|
errorHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.error"),
|
t("home.settings.quick_connect.error"),
|
||||||
@@ -119,6 +121,8 @@ 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 }}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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,
|
||||||
@@ -28,10 +27,11 @@ 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, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
import type { TrackInfo } 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,7 +50,6 @@ 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>;
|
||||||
@@ -58,7 +57,6 @@ 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;
|
||||||
@@ -208,8 +206,8 @@ export const Controls: FC<Props> = ({
|
|||||||
|
|
||||||
// Navigation hooks
|
// Navigation hooks
|
||||||
const {
|
const {
|
||||||
handleSeekBackward,
|
handleSeekBackward: asyncHandleSeekBackward,
|
||||||
handleSeekForward,
|
handleSeekForward: asyncHandleSeekForward,
|
||||||
handleSkipBackward,
|
handleSkipBackward,
|
||||||
handleSkipForward,
|
handleSkipForward,
|
||||||
} = useVideoNavigation({
|
} = useVideoNavigation({
|
||||||
@@ -220,6 +218,21 @@ 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,
|
||||||
@@ -377,7 +390,7 @@ export const Controls: FC<Props> = ({
|
|||||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
console.log("queryParams", queryParams);
|
writeDebugLog("controls.navigate.queryParams", { queryParams });
|
||||||
|
|
||||||
router.replace(`player/direct-player?${queryParams}` as any);
|
router.replace(`player/direct-player?${queryParams}` as any);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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";
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
chosenAudioIndex?: string;
|
chosenAudioIndex?: string;
|
||||||
chosenSubtitleIndex?: string;
|
chosenSubtitleIndex?: string;
|
||||||
}) => {
|
}) => {
|
||||||
console.log("chosenSubtitleIndex", chosenSubtitleIndex);
|
writeDebugLog("video.setPlayerParams", { chosenSubtitleIndex });
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
itemId: itemId ?? "",
|
itemId: itemId ?? "",
|
||||||
audioIndex: chosenAudioIndex,
|
audioIndex: chosenAudioIndex,
|
||||||
@@ -114,7 +115,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
mediaSource?.TranscodingUrl &&
|
mediaSource?.TranscodingUrl &&
|
||||||
!onTextBasedSubtitle;
|
!onTextBasedSubtitle;
|
||||||
|
|
||||||
console.log("Set player params", index, serverIndex);
|
writeDebugLog("video.setTrackParams", { type, index, serverIndex });
|
||||||
if (shouldChangePlayerParams) {
|
if (shouldChangePlayerParams) {
|
||||||
setPlayerParams({
|
setPlayerParams({
|
||||||
chosenSubtitleIndex: serverIndex.toString(),
|
chosenSubtitleIndex: serverIndex.toString(),
|
||||||
@@ -127,6 +128,57 @@ 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) {
|
||||||
@@ -140,73 +192,47 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
||||||
}
|
}
|
||||||
|
|
||||||
let embedSubIndex = 1;
|
const embedSubIndex = { current: 1 };
|
||||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
const processedSubs: Track[] = allSubs?.map((sub) =>
|
||||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
createSubtitleTrack(sub, subtitleData, embedSubIndex),
|
||||||
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.sort(
|
const subtitles: Track[] = processedSubs.toSorted(
|
||||||
(a, b) => a.index - b.index,
|
(a, b) => a.index - b.index,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add a "Disable Subtitles" option
|
// Add a "Disable Subtitles" option
|
||||||
subtitles.unshift({
|
const disableSubtitleTrack = {
|
||||||
name: "Disable",
|
name: "Disable",
|
||||||
index: -1,
|
index: -1,
|
||||||
setTrack: () =>
|
setTrack: () => {
|
||||||
!mediaSource?.TranscodingUrl || onTextBasedSubtitle
|
if (!mediaSource?.TranscodingUrl || onTextBasedSubtitle) {
|
||||||
? setTrackParams("subtitle", -1, -1)
|
setTrackParams("subtitle", -1, -1);
|
||||||
: setPlayerParams({ chosenSubtitleIndex: "-1" }),
|
} else {
|
||||||
});
|
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) =>
|
||||||
if (!mediaSource?.TranscodingUrl) {
|
createAudioTrack(audio, idx, audioData),
|
||||||
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) {
|
||||||
audioTracks.unshift({
|
const disableAudioTrack = {
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -214,16 +240,25 @@ 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
|
<VideoContext.Provider value={contextValue}>
|
||||||
value={{
|
|
||||||
audioTracks,
|
|
||||||
subtitleTracks,
|
|
||||||
setSubtitleTrack,
|
|
||||||
setSubtitleURL,
|
|
||||||
setAudioTrack,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</VideoContext.Provider>
|
</VideoContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, idx: number) => (
|
{BITRATES?.map((bitrate) => (
|
||||||
<View key={`quality-item-${idx}`}>
|
<View key={`quality-item-${bitrate.value}`}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
changeBitrate(bitrate.value?.toString() ?? "");
|
changeBitrate(bitrate.value?.toString() ?? "");
|
||||||
@@ -164,7 +164,8 @@ const DropdownView = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{idx < BITRATES.length - 1 && (
|
{BITRATES.findIndex((b) => b.value === bitrate.value) <
|
||||||
|
BITRATES.length - 1 && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
@@ -190,17 +191,17 @@ const DropdownView = () => {
|
|||||||
}}
|
}}
|
||||||
className='flex flex-col rounded-xl overflow-hidden'
|
className='flex flex-col rounded-xl overflow-hidden'
|
||||||
>
|
>
|
||||||
{subtitleTracks?.map((sub, idx: number) => (
|
{subtitleTracks?.map((subtitle) => (
|
||||||
<View key={`subtitle-item-${idx}`}>
|
<View key={`subtitle-item-${subtitle.index}`}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
sub.setTrack();
|
subtitle.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'>{sub.name}</Text>
|
<Text className='flex shrink'>{subtitle.name}</Text>
|
||||||
{subtitleIndex === sub.index.toString() ? (
|
{subtitleIndex === subtitle.index.toString() ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='radio-button-on'
|
name='radio-button-on'
|
||||||
size={24}
|
size={24}
|
||||||
@@ -214,7 +215,10 @@ const DropdownView = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{idx < (subtitleTracks?.length ?? 0) - 1 && (
|
{(subtitleTracks?.findIndex(
|
||||||
|
(s) => s.index === subtitle.index,
|
||||||
|
) ?? 0) <
|
||||||
|
(subtitleTracks?.length ?? 0) - 1 && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
@@ -240,17 +244,17 @@ const DropdownView = () => {
|
|||||||
}}
|
}}
|
||||||
className='flex flex-col rounded-xl overflow-hidden'
|
className='flex flex-col rounded-xl overflow-hidden'
|
||||||
>
|
>
|
||||||
{audioTracks?.map((track, idx: number) => (
|
{audioTracks?.map((audio) => (
|
||||||
<View key={`audio-item-${idx}`}>
|
<View key={`audio-item-${audio.index}`}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
track.setTrack();
|
audio.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'>{track.name}</Text>
|
<Text className='flex shrink'>{audio.name}</Text>
|
||||||
{audioIndex === track.index.toString() ? (
|
{audioIndex === audio.index.toString() ? (
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name='radio-button-on'
|
name='radio-button-on'
|
||||||
size={24}
|
size={24}
|
||||||
@@ -264,7 +268,10 @@ const DropdownView = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{idx < (audioTracks?.length ?? 0) - 1 && (
|
{(audioTracks?.findIndex(
|
||||||
|
(a) => a.index === audio.index,
|
||||||
|
) ?? 0) <
|
||||||
|
(audioTracks?.length ?? 0) - 1 && (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
||||||
import { atom } from "jotai";
|
import { atom, useAtom } 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";
|
||||||
@@ -88,11 +89,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}${MOVIE}s${STUDIO}`,
|
DISCOVER_MOVIES_STUDIO = "/discover/movies/studio",
|
||||||
AUTH_JELLYFIN = "/auth/jellyfin",
|
AUTH_JELLYFIN = "/auth/jellyfin",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,10 +385,10 @@ export class JellyseerrApi {
|
|||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
(error: AxiosError) => {
|
||||||
writeErrorLog(
|
writeErrorLog(
|
||||||
`Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
|
`Jellyseerr response error\nerror: ${String(error)}\nurl: ${error?.config?.url}`,
|
||||||
error.response?.data,
|
error.response?.data,
|
||||||
);
|
);
|
||||||
if (error.response?.status === 403) {
|
if (error.status === 403) {
|
||||||
clearJellyseerrStorageData();
|
clearJellyseerrStorageData();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
@@ -468,49 +469,77 @@ export const useJellyseerr = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isJellyseerrMovieOrTvResult = (
|
const isJellyseerrMovieOrTvResult = (
|
||||||
items: any | null | undefined,
|
items: MediaItem | null | undefined,
|
||||||
): items is MovieResult | TvResult => {
|
): items is MovieResult | TvResult => {
|
||||||
return (
|
return Boolean(
|
||||||
items &&
|
items &&
|
||||||
Object.hasOwn(items, "mediaType") &&
|
Object.hasOwn(items, "mediaType") &&
|
||||||
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
|
((items as MovieResult | TvResult).mediaType === MediaType.MOVIE ||
|
||||||
|
(items as MovieResult | TvResult).mediaType === MediaType.TV),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTitle = (
|
type MediaItem =
|
||||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
| TvResult
|
||||||
) => {
|
| TvDetails
|
||||||
return isJellyseerrMovieOrTvResult(item)
|
| MovieResult
|
||||||
? item.mediaType === MediaType.MOVIE
|
| MovieDetails
|
||||||
? item?.title
|
| PersonCreditCast
|
||||||
: item?.name
|
| CollectionResult
|
||||||
: item?.mediaInfo?.mediaType === MediaType.MOVIE
|
| PersonResult;
|
||||||
? (item as MovieDetails)?.title
|
|
||||||
: (item as TvDetails)?.name;
|
const getTitle = (item?: MediaItem) => {
|
||||||
|
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 = (
|
const getYear = (item?: MediaItem) => {
|
||||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
let dateString = "";
|
||||||
) => {
|
|
||||||
return new Date(
|
if (isJellyseerrMovieOrTvResult(item)) {
|
||||||
(isJellyseerrMovieOrTvResult(item)
|
dateString =
|
||||||
? item.mediaType === MediaType.MOVIE
|
(item.mediaType === MediaType.MOVIE
|
||||||
? item?.releaseDate
|
? item?.releaseDate
|
||||||
: item?.firstAirDate
|
: item?.firstAirDate) || "";
|
||||||
: item?.mediaInfo?.mediaType === MediaType.MOVIE
|
} else if (item && "mediaInfo" in item) {
|
||||||
? (item as MovieDetails)?.releaseDate
|
if (item.mediaInfo?.mediaType === MediaType.MOVIE) {
|
||||||
: (item as TvDetails)?.firstAirDate) || "",
|
dateString = (item as unknown as MovieDetails)?.releaseDate || "";
|
||||||
)?.getFullYear?.();
|
} else {
|
||||||
|
dateString = (item as unknown as TvDetails)?.firstAirDate || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(dateString)?.getFullYear?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMediaType = (
|
const getMediaType = (item?: MediaItem): MediaType | undefined => {
|
||||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
if (isJellyseerrMovieOrTvResult(item)) {
|
||||||
): MediaType => {
|
return item.mediaType as MediaType;
|
||||||
return isJellyseerrMovieOrTvResult(item)
|
}
|
||||||
? (item.mediaType as MediaType)
|
|
||||||
: item?.mediaInfo?.mediaType;
|
if (item && "mediaInfo" in item) {
|
||||||
|
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],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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,
|
||||||
@@ -184,11 +185,19 @@ 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((t: any) => t.metadata && !processes.some((p) => p.id === t.id))
|
.filter(hasMetadataAndNotInProcesses)
|
||||||
.map((t: any) => {
|
.map((task: any) => {
|
||||||
return t.metadata as JobStatus;
|
return task.metadata as JobStatus;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentProcesses = [...processes, ...missingProcesses];
|
const currentProcesses = [...processes, ...missingProcesses];
|
||||||
@@ -284,7 +293,7 @@ function useDownloadProvider() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return p;
|
return process;
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedProcesses;
|
return updatedProcesses;
|
||||||
@@ -668,7 +677,7 @@ function useDownloadProvider() {
|
|||||||
// Handle other media types
|
// Handle other media types
|
||||||
db.other[item.Id] = downloadedItem;
|
db.other[item.Id] = downloadedItem;
|
||||||
}
|
}
|
||||||
await saveDownloadsDatabase(db);
|
saveDownloadsDatabase(db);
|
||||||
|
|
||||||
// Send native notification for successful download
|
// Send native notification for successful download
|
||||||
const successNotification = getNotificationContent(
|
const successNotification = getNotificationContent(
|
||||||
@@ -828,12 +837,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",
|
||||||
@@ -865,60 +874,61 @@ function useDownloadProvider() {
|
|||||||
[authHeader, startDownload],
|
[authHeader, startDownload],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => {
|
const findAndDeleteEpisode = (
|
||||||
const db = getDownloadsDatabase();
|
db: DownloadsDatabase,
|
||||||
let downloadedItem: DownloadedItem | undefined;
|
id: string,
|
||||||
|
): DownloadedItem | undefined => {
|
||||||
if (type === "Movie" && Object.entries(db.movies).length !== 0) {
|
for (const [seriesId, series] of Object.entries(db.series)) {
|
||||||
downloadedItem = db.movies[id];
|
for (const [seasonNumber, season] of Object.entries(series.seasons)) {
|
||||||
if (downloadedItem) {
|
for (const [episodeNumber, episode] of Object.entries(
|
||||||
delete db.movies[id];
|
season.episodes,
|
||||||
}
|
)) {
|
||||||
} else if (type === "Episode" && Object.entries(db.series).length !== 0) {
|
if (episode.item.Id === id) {
|
||||||
const cleanUpEmptyParents = (
|
const downloadedItem = episode;
|
||||||
series: any,
|
delete season.episodes[Number(episodeNumber)];
|
||||||
seasonNumber: string,
|
cleanUpEmptyParents(series, Number(seasonNumber), seriesId, db);
|
||||||
seriesId: string,
|
return downloadedItem;
|
||||||
) => {
|
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadedItem?.videoFilePath) {
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
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" &&
|
||||||
@@ -931,13 +941,32 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
await saveDownloadsDatabase(db);
|
const deleteFile = async (id: string, type: BaseItemDto["Type"]) => {
|
||||||
|
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();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -977,6 +1006,25 @@ 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,
|
||||||
@@ -988,15 +1036,7 @@ function useDownloadProvider() {
|
|||||||
} else if (db.other[itemId]) {
|
} else if (db.other[itemId]) {
|
||||||
db.other[itemId] = updatedItem;
|
db.other[itemId] = updatedItem;
|
||||||
} else {
|
} else {
|
||||||
for (const series of Object.values(db.series)) {
|
updateEpisodeInSeries(db, itemId, updatedItem);
|
||||||
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);
|
||||||
};
|
};
|
||||||
@@ -1281,7 +1321,9 @@ export function useDownload() {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
export function DownloadProvider({
|
||||||
|
children,
|
||||||
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
const downloadUtils = useDownloadProvider();
|
const downloadUtils = useDownloadProvider();
|
||||||
return (
|
return (
|
||||||
<DownloadContext.Provider value={downloadUtils}>
|
<DownloadContext.Provider value={downloadUtils}>
|
||||||
|
|||||||
@@ -333,28 +333,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ 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;
|
||||||
@@ -135,7 +134,7 @@ export type HomeSectionLatestResolver = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export enum VideoPlayer {
|
export enum VideoPlayer {
|
||||||
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
|
// NATIVE, // Reserved for future native playback implementation
|
||||||
VLC_3 = 0,
|
VLC_3 = 0,
|
||||||
VLC_4 = 1,
|
VLC_4 = 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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";
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export const getDownloadUrl = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
|
||||||
console.log("Downloading item directly");
|
writeDebugLog("download.directDownload", { itemId: item.Id });
|
||||||
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,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 {
|
||||||
@@ -45,7 +46,7 @@ const getPlaybackUrl = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Video is being transcoded:", transcodeUrl);
|
writeDebugLog("media.stream.transcoded", { transcodeUrl });
|
||||||
return `${api.basePath}${transcodeUrl}`;
|
return `${api.basePath}${transcodeUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ const getPlaybackUrl = (
|
|||||||
|
|
||||||
const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`;
|
const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`;
|
||||||
|
|
||||||
console.log("Video is being direct played:", directPlayUrl);
|
writeDebugLog("media.stream.directPlay", { directPlayUrl });
|
||||||
return directPlayUrl;
|
return directPlayUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,7 +165,11 @@ export const getStreamUrl = async ({
|
|||||||
mediaSource: MediaSourceInfo | undefined;
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
} | null> => {
|
} | null> => {
|
||||||
if (!api || !userId || !item?.Id) {
|
if (!api || !userId || !item?.Id) {
|
||||||
console.warn("Missing required parameters for getStreamUrl");
|
writeDebugLog("media.stream.missingParams", {
|
||||||
|
hasApi: !!api,
|
||||||
|
hasUserId: !!userId,
|
||||||
|
hasItemId: !!item?.Id,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +178,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) {
|
||||||
console.log("Item is of type program...");
|
writeDebugLog("media.stream.programDetected", { itemId: item.Id });
|
||||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||||
{
|
{
|
||||||
userId,
|
userId,
|
||||||
@@ -233,7 +238,10 @@ export const getStreamUrl = async ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
console.error("Error getting playback info:", res.status, res.statusText);
|
writeDebugLog("media.stream.playbackInfoError", {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionId = res.data.PlaySessionId || null;
|
sessionId = res.data.PlaySessionId || null;
|
||||||
@@ -280,7 +288,11 @@ export const getDownloadStreamUrl = async ({
|
|||||||
mediaSource: MediaSourceInfo | undefined;
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
} | null> => {
|
} | null> => {
|
||||||
if (!api || !userId || !item?.Id) {
|
if (!api || !userId || !item?.Id) {
|
||||||
console.warn("Missing required parameters for getStreamUrl");
|
writeDebugLog("media.downloadStream.missingParams", {
|
||||||
|
hasApi: !!api,
|
||||||
|
hasUserId: !!userId,
|
||||||
|
hasItemId: !!item?.Id,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +317,10 @@ export const getDownloadStreamUrl = async ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
console.error("Error getting playback info:", res.status, res.statusText);
|
writeDebugLog("media.downloadStream.playbackInfoError", {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = res.data.PlaySessionId || null;
|
const sessionId = res.data.PlaySessionId || null;
|
||||||
|
|||||||
19
utils/navigation.ts
Normal file
19
utils/navigation.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user