Compare commits

..

12 Commits

Author SHA1 Message Date
Fredrik Burmester
ece40534ac Fix carousel behavior and settings option 2025-11-07 08:46:10 +01:00
Fredrik Burmester
ce37351099 fix(android): possible fix for notifications 2025-11-07 08:08:01 +01:00
Fredrik Burmester
e4e1e556bf chore: version 2025-11-07 08:07:02 +01:00
lostb1t
1ff723b29f Update ItemContent.tsx
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-06 16:16:51 +01:00
lostb1t
11d35c846d Update ItemContent.tsx 2025-11-06 16:10:12 +01:00
lostb1t
ce82f3044b Update ItemContent.tsx
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
2025-11-06 13:34:55 +01:00
lostb1t
df638dae28 Update ItemContent.tsx 2025-11-06 13:32:28 +01:00
Jonah
9a5e49ae16 fix: omit includeItemTypes in getLatestMedia if possible (#1141)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
2025-11-04 16:22:10 +01:00
renovate[bot]
4f2120f85d chore(deps): Update github/codeql-action action to v4.31.2 (#1152)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-31 10:20:39 +01:00
Fredrik Burmester
8eeea35441 chore: bump version to 0.40.0 2025-10-31 08:37:50 +01:00
Fredrik Burmester
781464b768 chore: update to bun 1.3 2025-10-31 08:25:02 +01:00
renovate[bot]
61d60c2e74 chore(deps): Update dependency @biomejs/biome to v2.2.7 (#1142)
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 14:39:42 +01:00
51 changed files with 1022 additions and 1613 deletions

View File

@@ -31,13 +31,13 @@ jobs:
fetch-depth: 0
- name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
- name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2

27
.gitignore vendored
View File

@@ -1,34 +1,7 @@
# Dependencies and Package Managers
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.lockb
>>>>>>> origin/develop
package-lock.json
# Expo and React Native Build Artifacts

View File

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

View File

@@ -10,10 +10,15 @@ module.exports = ({ config }) => {
// Add the background downloader plugin only for non-TV builds
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
}
// Only override googleServicesFile if env var is set
const androidConfig = {};
if (process.env.GOOGLE_SERVICES_JSON) {
androidConfig.googleServicesFile = process.env.GOOGLE_SERVICES_JSON;
}
return {
android: {
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
},
...(Object.keys(androidConfig).length > 0 && { android: androidConfig }),
...config,
};
};

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.39.0",
"version": "0.40.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -37,7 +37,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 71,
"versionCode": 73,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png",

View File

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

View File

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

View File

@@ -1,18 +1,3 @@
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
interface HeaderRightProps {
readonly details: MovieDetails | TvDetails | null | undefined;
}
function HeaderRight({ details }: HeaderRightProps) {
if (!details) return null;
return (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
);
}
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
@@ -57,6 +42,7 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -78,7 +64,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, setRequestBody] = useState<MediaRequestBody>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -129,18 +115,18 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const handleSetRequestBody = useCallback(
const setRequestBody = useCallback(
(body: MediaRequestBody) => {
setRequestBody(body);
_setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
[requestBody, setRequestBody, advancedReqModalRef],
[requestBody, _setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: mediaType,
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -148,12 +134,20 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
handleSetRequestBody(body);
setRequestBody(body);
return;
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
}, [
details,
result,
requestMedia,
hasAdvancedRequestPermission,
mediaTitle,
refetch,
mediaType,
]);
const isAnime = useMemo(
() =>
@@ -162,81 +156,17 @@ const Page: React.FC = () => {
[details],
);
const headerRightComponent = useMemo(
() => <HeaderRight details={details as MovieDetails | TvDetails | null} />,
[details],
);
useEffect(() => {
navigation.setOptions({
headerRight: () => headerRightComponent,
});
}, [headerRightComponent, navigation]);
const renderActionButton = () => {
if (isLoading || isFetching) {
return (
<Button
loading={true}
disabled={true}
color='purple'
className='mt-4'
/>
);
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
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;
};
}, [details]);
return (
<View
@@ -388,7 +318,7 @@ const Page: React.FC = () => {
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) => handleSetRequestBody(data)}
onAdvancedRequest={(data) => setRequestBody(data)}
/>
)}
<DetailFacts
@@ -407,11 +337,11 @@ const Page: React.FC = () => {
type={mediaType}
isAnime={isAnime}
onRequested={() => {
setRequestBody(undefined);
_setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => setRequestBody(undefined)}
onDismiss={() => _setRequestBody(undefined)}
/>
{!Platform.isTV && (
// This is till it's fixed because the menu isn't selectable on TV

View File

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

View File

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

View File

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

View File

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

View File

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

582
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ interface AppleTVCarouselProps {
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// Layout Constants
const CAROUSEL_HEIGHT = screenHeight / 1.45;
export const APPLE_TV_CAROUSEL_HEIGHT = screenHeight / 1.45;
const GRADIENT_HEIGHT_TOP = 150;
const GRADIENT_HEIGHT_BOTTOM = 150;
const LOGO_HEIGHT = 80;
@@ -307,7 +307,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
const navigateToItem = useCallback(
(item: BaseItemDto) => {
const navigation = getItemNavigation(item, "(home)");
router.push(navigation as `/(auth)/(tabs)/${string}`);
router.push(navigation as any);
},
[router],
);
@@ -381,7 +381,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<View
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
height: APPLE_TV_CAROUSEL_HEIGHT,
backgroundColor: "#000",
}}
>
@@ -549,7 +549,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
key={item.Id}
style={{
width: screenWidth,
height: CAROUSEL_HEIGHT,
height: APPLE_TV_CAROUSEL_HEIGHT,
position: "relative",
}}
>
@@ -731,7 +731,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return (
<View
style={{
height: CAROUSEL_HEIGHT,
height: APPLE_TV_CAROUSEL_HEIGHT,
backgroundColor: "#000",
overflow: "hidden",
}}
@@ -749,7 +749,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
return (
<View
style={{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
height: APPLE_TV_CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
backgroundColor: "#000",
overflow: "hidden",
}}
@@ -758,7 +758,7 @@ export const AppleTVCarousel: React.FC<AppleTVCarouselProps> = ({
<Animated.View
style={[
{
height: CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
height: APPLE_TV_CAROUSEL_HEIGHT, // Fixed height instead of flex: 1
flexDirection: "row",
width: screenWidth * items.length,
},

View File

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

View File

@@ -1,6 +1,19 @@
import { LinearGradient } from "expo-linear-gradient";
import type { PropsWithChildren, ReactElement } from "react";
import { type NativeScrollEvent, View, type ViewProps } from "react-native";
import type {
MutableRefObject,
PropsWithChildren,
ReactElement,
Ref,
} from "react";
import { useEffect } from "react";
import {
type NativeScrollEvent,
type ScrollViewProps,
type StyleProp,
View,
type ViewProps,
type ViewStyle,
} from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -14,6 +27,9 @@ interface Props extends ViewProps {
episodePoster?: ReactElement;
headerHeight?: number;
onEndReached?: (() => void) | null | undefined;
scrollViewProps?: Animated.AnimatedProps<ScrollViewProps>;
contentContainerStyle?: StyleProp<ViewStyle>;
scrollViewRef?: Ref<Animated.ScrollView>;
}
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
@@ -23,10 +39,33 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
headerHeight = 400,
logo,
onEndReached,
contentContainerStyle,
scrollViewProps,
scrollViewRef,
...props
}: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const animatedScrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(animatedScrollRef);
const {
onScroll: externalOnScroll,
style: scrollStyle,
scrollEventThrottle: externalScrollEventThrottle,
...restScrollViewProps
} = scrollViewProps ?? {};
useEffect(() => {
if (!scrollViewRef) return;
const node = animatedScrollRef.current;
if (typeof scrollViewRef === "function") {
scrollViewRef(node);
return () => scrollViewRef(null);
}
(scrollViewRef as MutableRefObject<Animated.ScrollView | null>).current =
node;
}, [animatedScrollRef, scrollViewRef]);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
@@ -62,12 +101,17 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
return (
<View className='flex-1' {...props}>
<Animated.ScrollView
style={{
position: "relative",
}}
ref={scrollRef}
scrollEventThrottle={16}
{...restScrollViewProps}
style={[
{
position: "relative",
},
scrollStyle,
]}
ref={animatedScrollRef}
scrollEventThrottle={externalScrollEventThrottle ?? 16}
onScroll={(e) => {
externalOnScroll?.(e);
if (isCloseToBottom(e.nativeEvent)) onEndReached?.();
}}
>
@@ -96,9 +140,12 @@ export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
</Animated.View>
<View
style={{
top: -50,
}}
style={[
{
top: -50,
},
contentContainerStyle,
]}
className='relative flex-1 bg-transparent pb-24'
>
<LinearGradient

View File

@@ -6,9 +6,8 @@ import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, TouchableOpacity, View } from "react-native";
import {
import CastContext, {
CastButton,
CastContext,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -47,63 +46,6 @@ interface Props extends React.ComponentProps<typeof Button> {
const ANIMATION_DURATION = 500;
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> = ({
item,
selectedOptions,
@@ -144,86 +86,12 @@ export const PlayButton: React.FC<Props> = ({
[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 () => {
console.log("onPress");
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -233,11 +101,14 @@ export const PlayButton: React.FC<Props> = ({
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString);
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
@@ -250,23 +121,137 @@ export const PlayButton: React.FC<Props> = ({
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name;
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
await handleChromecast({
item,
api,
user,
selectedOptions,
client,
t,
settings,
isOpeningCurrentlyPlayingMedia:
!!isOpeningCurrentlyPlayingMedia,
});
// Check if user wants H265 for Chromecast
const enableH265 = settings.enableH265ForChromecast;
// Validate required parameters before calling getStreamUrl
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;
}
// 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;
@@ -288,14 +273,10 @@ export const PlayButton: React.FC<Props> = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
lightHapticFeedback,
goToPlayer,
isOffline,
handleChromecast,
]);
const derivedTargetWidth = useDerivedValue(() => {
if (!item?.RunTimeTicks) return 0;
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,3 @@
function renderHeaderLeft(hasDownloads: boolean, onPress: () => void) {
return (
<DownloadsHeaderButton hasDownloads={hasDownloads} onPress={onPress} />
);
}
// ...imports...
const DownloadsHeaderButton: React.FC<{
hasDownloads: boolean;
onPress: () => void;
}> = ({ hasDownloads, onPress }) => (
<TouchableOpacity onPress={onPress} className='p-2'>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
);
import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
@@ -36,14 +16,15 @@ import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { useNavigation, useRouter, useSegments } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Animated from "react-native-reanimated";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
ScrollView,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
@@ -58,7 +39,8 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { AppleTVCarousel } from "../AppleTVCarousel";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { AppleTVCarousel, APPLE_TV_CAROUSEL_HEIGHT } from "../AppleTVCarousel";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
@@ -86,12 +68,13 @@ export const HomeIndex = () => {
const [loading, setLoading] = useState(false);
const { settings, refreshStreamyfinPluginSettings } = useSettings();
const showCarousel = settings?.showHomeCarousel ?? true;
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const scrollViewRef = useRef<ScrollView>(null);
const scrollViewRef = useRef<Animated.ScrollView | null>(null);
const { getDownloadedItems, cleanCacheDirectory } = useDownload();
const prevIsConnected = useRef<boolean | null>(false);
@@ -120,8 +103,20 @@ export const HomeIndex = () => {
}
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({
headerLeft: () =>
renderHeaderLeft(hasDownloads, () => router.push("/(auth)/downloads")),
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className='p-2'
>
<Feather
name='download'
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [navigation, router]);
@@ -131,12 +126,12 @@ export const HomeIndex = () => {
);
}, []);
const segments = useSegments() as string[];
const segments = useSegments();
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
if ((segments as string[])[2] === "(home)")
scrollViewRef.current?.scrollTo({
y: Platform.isTV ? -152 : -100,
y: 0,
animated: true,
});
});
@@ -224,7 +219,9 @@ export const HomeIndex = () => {
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
c.CollectionType === "tvshows" || c.CollectionType === "movies"
? []
: ["Movie"];
const title = t("home.recently_added_in", { libraryName: c.Name });
const queryKey = [
"home",
@@ -326,10 +323,10 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) {
const id = `section-${index}`;
const id = section.title || `section-${index}`;
ss.push({
title: t(`${id}`),
queryKey: ["home", id],
queryKey: ["home", "custom", String(index), section.title ?? null],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
@@ -387,8 +384,8 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
let title: string = "";
let subtitle: string = "";
let title = "";
let subtitle = "";
if (!isConnected) {
// No network connection
@@ -461,25 +458,36 @@ export const HomeIndex = () => {
</View>
);
const headerHeight = showCarousel ? APPLE_TV_CAROUSEL_HEIGHT : 120;
const refreshProgressOffset = showCarousel ? 200 : 80;
return (
<ScrollView
scrollToOverflowEnabled={true}
ref={scrollViewRef}
nestedScrollEnabled
contentInsetAdjustmentBehavior='never'
refreshControl={
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white' // For iOS
colors={["white"]} // For Android
progressViewOffset={200} // This offsets the refresh indicator to appear over the carousel
/>
<ParallaxScrollView
scrollViewRef={scrollViewRef}
headerHeight={headerHeight}
headerImage={
showCarousel ? (
<AppleTVCarousel initialIndex={0} />
) : (
<View className='flex-1 bg-black' />
)
}
style={{ marginTop: Platform.isTV ? 0 : -100 }}
contentContainerStyle={{ paddingTop: Platform.isTV ? 0 : 100 }}
contentContainerStyle={showCarousel ? undefined : { top: 0 }}
scrollViewProps={{
refreshControl: (
<RefreshControl
refreshing={loading}
onRefresh={refetch}
tintColor='white'
colors={["white"]}
progressViewOffset={refreshProgressOffset}
/>
),
contentInsetAdjustmentBehavior: "never",
nestedScrollEnabled: true,
scrollToOverflowEnabled: true,
}}
>
<AppleTVCarousel initialIndex={0} />
<View
style={{
paddingLeft: insets.left,
@@ -515,7 +523,7 @@ export const HomeIndex = () => {
</View>
</View>
<View className='h-24' />
</ScrollView>
</ParallaxScrollView>
);
};

View File

@@ -62,6 +62,7 @@ export const OtherSettings: React.FC = () => {
pluginSettings?.followDeviceOrientation?.locked === true &&
pluginSettings?.defaultVideoOrientation?.locked === true &&
pluginSettings?.safeAreaInControlsEnabled?.locked === true &&
pluginSettings?.showHomeCarousel?.locked === true &&
pluginSettings?.showCustomMenuLinks?.locked === true &&
pluginSettings?.hiddenLibraries?.locked === true &&
pluginSettings?.disableHapticFeedback?.locked === true,
@@ -158,6 +159,19 @@ export const OtherSettings: React.FC = () => {
/>
</ListItem>
<ListItem
title={t("home.settings.other.show_home_carousel")}
disabled={pluginSettings?.showHomeCarousel?.locked}
>
<Switch
value={settings.showHomeCarousel}
disabled={pluginSettings?.showHomeCarousel?.locked}
onValueChange={(value) =>
updateSettings({ showHomeCarousel: value })
}
/>
</ListItem>
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
<ListItem
title={t("home.settings.other.video_player")}

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,14 +45,14 @@
},
"production": {
"environment": "production",
"channel": "0.39.0",
"channel": "0.40.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"environment": "production",
"channel": "0.39.0",
"channel": "0.40.1",
"android": {
"buildType": "apk",
"image": "latest"
@@ -60,7 +60,7 @@
},
"production-apk-tv": {
"environment": "production",
"channel": "0.39.0",
"channel": "0.40.1",
"android": {
"buildType": "apk",
"image": "latest"

View File

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

View File

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

View File

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

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.39.0" },
clientInfo: { name: "Streamyfin", version: "0.40.1" },
deviceInfo: {
name: deviceName,
id,
@@ -87,7 +87,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.39.0"`,
}, DeviceId="${deviceId}", Version="0.40.1"`,
};
}, [deviceId]);

View File

@@ -176,6 +176,7 @@
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe Area in Controls",
"show_home_carousel": "Show Home Carousel",
"video_player": "Video Player",
"video_players": {
"VLC_3": "VLC 3",
@@ -333,7 +334,28 @@
"collections": "Collections",
"actors": "Actors",
"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": {
"no_results": "No Results",

View File

@@ -88,6 +88,7 @@ export type Home = {
};
export type HomeSection = {
title?: string;
orientation?: "horizontal" | "vertical";
items?: HomeSectionItemResolver;
nextUp?: HomeSectionNextUpResolver;
@@ -134,7 +135,7 @@ export type HomeSectionLatestResolver = {
};
export enum VideoPlayer {
// NATIVE, // Reserved for future native playback implementation
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
VLC_3 = 0,
VLC_4 = 1,
}
@@ -159,6 +160,7 @@ export type Settings = {
subtitleMode: SubtitlePlaybackMode;
rememberSubtitleSelections: boolean;
showHomeTitles: boolean;
showHomeCarousel: boolean;
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
@@ -227,6 +229,7 @@ export const defaultValues: Settings = {
subtitleMode: SubtitlePlaybackMode.Default,
rememberSubtitleSelections: true,
showHomeTitles: true,
showHomeCarousel: true,
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30,
rewindSkipTime: 10,

View File

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

View File

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

View File

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