Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
6c6a0f69ae wip 2024-08-21 09:13:17 +02:00
56 changed files with 973 additions and 1649 deletions

1
.gitignore vendored
View File

@@ -29,4 +29,3 @@ pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json

View File

@@ -26,33 +26,18 @@ Streamyfin includes some exciting experimental features like media downloading a
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
### Chromecast
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
## Plugins
In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
### Collection rows
Jellyfin collections can be shown as rows or carousel on the home screen.
The following tags can be added to an collection to provide this functionality.
The following tags can be added to an collection to provide this functionality.
Avaiable tags:
- sf_promoted: Wil make the collection an row on home
- sf_carousel: Wil make the collection an carousel on home.
A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
### Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## Roadmap for V1
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
@@ -87,12 +72,6 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info
1. Use node `20`
2. Install deps `bun i`
3. `Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`.
## Extended chromecast controls
Add this to AppDelegate.mm:
```

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.8.2",
"version": "0.7.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -30,7 +30,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 23,
"versionCode": 19,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},

View File

@@ -72,7 +72,6 @@ export default function index() {
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
})
).data.Items) ||
[],

View File

@@ -1,34 +1,21 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, View } from "react-native";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentCollectionIdAtom,
genreFilterAtom,
sortByAtom,
sortOptions,
sortByOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
@@ -37,17 +24,36 @@ import {
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 200;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
const Page = () => {
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: string };
const [, setCurrentCollectionId] = useAtom(currentCollectionIdAtom);
useEffect(() => {
setCurrentCollectionId(libraryId);
}, [libraryId]);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
@@ -55,41 +61,6 @@ const Page = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useLayoutEffect(() => {
setSortBy([
{
key: "SortName",
value: "Name",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const { data: library } = useQuery({
queryKey: ["library", libraryId],
queryFn: async () => {
@@ -98,7 +69,8 @@ const Page = () => {
itemId: libraryId,
userId: user?.Id,
});
return response.data;
const data = response.data;
return data;
},
enabled: !!api && !!user?.Id && !!libraryId,
staleTime: 0,
@@ -112,7 +84,7 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
let includeItemTypes: BaseItemKind[] | undefined = [];
const includeItemTypes: BaseItemKind[] = [];
switch (library?.CollectionType) {
case "movies":
@@ -128,14 +100,13 @@ const Page = () => {
includeItemTypes.push("MusicAlbum");
break;
default:
includeItemTypes = undefined;
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
limit: 20,
limit: 66,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
@@ -164,10 +135,10 @@ const Page = () => {
]
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey: [
"library-items",
libraryId,
library,
selectedGenres,
selectedYears,
selectedTags,
@@ -199,236 +170,175 @@ const Page = () => {
enabled: !!api && !!user?.Id && !!library,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
const type = useMemo(() => {
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
}, [data]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
),
[orientation]
);
const flatData = useMemo(() => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
queryFn={async () => sortOptions}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
libraryId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
]
);
if (!library) return null;
if (!library || !library.CollectionType) return null;
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
></View>
)}
/>
scrollEventThrottle={400}
>
<View className="mt-4 mb-24">
<View className="mb-4">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="flex flex-row space-x-1 px-3">
<ResetFiltersButton />
<FilterButton
collectionId={libraryId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: libraryId,
});
return response.data.Genres || [];
}}
set={(value) => setSelectedGenres(value, libraryId)}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={libraryId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: libraryId,
});
return response.data.Tags || [];
}}
set={(value) => setSelectedTags(value, libraryId)}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={libraryId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: libraryId,
});
return (
response.data.Years?.sort((a, b) => b - a).map((y) =>
y.toString()
) || []
);
}}
set={(value) => setSelectedYears(value, libraryId)}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
icon="sort"
collectionId={libraryId}
queryKey="sortByFilter"
queryFn={async () => {
return sortByOptions;
}}
set={(value) => setSortBy(value, libraryId)}
values={sortBy}
title="Sort by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
showSearch={false}
/>
<FilterButton
icon="sort"
showSearch={false}
collectionId={libraryId}
queryKey="orderByFilter"
queryFn={async () => {
return sortOrderOptions;
}}
set={(value) => setSortOrder(value, libraryId)}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
</View>
</ScrollView>
{!type && isFetching && (
<Loader
style={{
marginTop: 300,
}}
/>
)}
</View>
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
{flatData.map(
(item, index) =>
item && (
<TouchableItemRouter
key={`${item.Id}-${index}`}
style={{
width: "32%",
marginBottom: 4,
}}
item={item}
className={`
`}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)
)}
{flatData.length % 3 !== 0 && (
<View
style={{
width: "33%",
}}
></View>
)}
</View>
</View>
</ScrollView>
);
};
export default React.memo(Page);
export default page;

View File

@@ -1,6 +1,7 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { currentCollectionIdAtom } from "@/utils/atoms/filters";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
@@ -66,6 +67,10 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
const [api] = useAtom(apiAtom);
const [currentCollection, setCurrentCollection] = useAtom(
currentCollectionIdAtom
);
const url = useMemo(
() =>
getPrimaryImageUrl({
@@ -80,6 +85,8 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
return (
<TouchableOpacity
onPress={() => {
if (!library.Id) return;
setCurrentCollection(library.Id);
router.push(`/libraries/${library.Id}`);
}}
>

View File

@@ -157,26 +157,6 @@ export default function search() {
enabled: debouncedSearch.length > 0,
});
const { data: collections, isFetching: l7 } = useQuery({
queryKey: ["search", "collections", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["BoxSet"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: actors, isFetching: l8 } = useQuery({
queryKey: ["search", "actors", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Person"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
@@ -214,15 +194,13 @@ export default function search() {
songs?.length ||
movies?.length ||
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length
series?.length
);
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
}, [artists, episodes, albums, songs, movies, series]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return l1 || l2 || l3 || l4 || l5 || l6;
}, [l1, l2, l3, l4, l5, l6]);
return (
<>
@@ -317,46 +295,6 @@ export default function search() {
/>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-28"
onPress={() => router.push(`/collections/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"

View File

@@ -1,151 +0,0 @@
import { Bitrate } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
import { Ratings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
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 { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { getItemsApi, getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { actorId } = local as { actorId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", actorId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: actorId,
}),
enabled: !!actorId && !!api,
staleTime: 60,
});
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
startIndex: pageParam,
limit: 8,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
collapseBoxSetItems: false,
});
return response.data;
},
[api, user?.Id, actorId]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item]
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="flex flex-col space-y-4 my-4">
<View className="px-4 mb-4">
<MoviesTitleHeader item={item} className="mb-4" />
<OverviewText text={item.Overview} />
</View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
Appeared In
</Text>
<InfiniteHorizontalScroll
height={247}
renderItem={(i, idx) => (
<TouchableItemRouter
key={idx}
item={i}
className={`flex flex-col
${"w-28"}
`}
>
<View>
<MoviePoster item={i} />
<ItemCardText item={i} />
</View>
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
/>
<View className="h-12"></View>
</View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -84,7 +84,7 @@ export default function page() {
useEffect(() => {
navigation.setOptions({
title: albums?.Items?.[0]?.AlbumArtist || "",
title: albums?.Items?.[0].AlbumArtist,
});
}, [albums]);

View File

@@ -7,16 +7,16 @@ import { Loader } from "@/components/Loader";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentCollectionIdAtom,
genreFilterAtom,
sortByAtom,
sortOptions,
sortByOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
BaseItemDto,
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
@@ -25,21 +25,23 @@ import {
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 200;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -48,29 +50,35 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [currentCollection, setCurrentCollection] = useAtom(
currentCollectionIdAtom
);
useLayoutEffect(() => {
setSortBy([
{
key: "PremiereDate",
value: "Premiere Date",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
useEffect(() => {
setSortBy(
[
{
key: "PremiereDate",
value: "Premiere Date",
},
],
collectionId
);
setSortOrder(
[
{
key: "Ascending",
value: "Ascending",
},
],
collectionId
);
}, []);
const { data: collection } = useQuery({
@@ -85,7 +93,7 @@ const page: React.FC = () => {
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 60 * 1000,
staleTime: 0,
});
useEffect(() => {
@@ -132,7 +140,7 @@ const page: React.FC = () => {
]
);
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey: [
"collection-items",
collection,
@@ -167,235 +175,178 @@ const page: React.FC = () => {
enabled: !!api && !!user?.Id && !!collection,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
useEffect(() => {
console.log("Data: ", data);
}, [data]);
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
),
[orientation]
);
const type = useMemo(() => {
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
}, [data]);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={setSelectedGenres}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "year",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Years || [];
}}
set={setSelectedYears}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)}
/>
),
},
{
key: "tags",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={setSelectedTags}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortBy",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="sortBy"
queryFn={async () => sortOptions}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
collectionId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
]
);
const flatData = useMemo(() => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
if (!collection) return null;
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
<ScrollView
contentInsetAdjustmentBehavior="automatic"
data={flatData}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemSize={255}
numColumns={
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
}
onEndReached={() => {
if (hasNextPage) {
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
ListHeaderComponent={ListHeaderComponent}
contentContainerStyle={{ paddingBottom: 24 }}
ItemSeparatorComponent={() => (
<View
style={{
width: 10,
height: 10,
}}
></View>
)}
/>
scrollEventThrottle={400}
>
<View className="mt-4 mb-24">
<View className="mb-4">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="flex flex-row space-x-1 px-3">
<ResetFiltersButton />
<FilterButton
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return response.data.Genres || [];
}}
set={(value) => setSelectedGenres(value, collectionId)}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return response.data.Tags || [];
}}
set={(value) => setSelectedTags(value, collectionId)}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return (
response.data.Years?.sort((a, b) => b - a).map((y) =>
y.toString()
) || []
);
}}
set={(value) => setSelectedYears(value, collectionId)}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
icon="sort"
collectionId={collectionId}
queryKey="sortByFilter"
queryFn={async () => {
return sortByOptions;
}}
set={(value) => setSortBy(value, collectionId)}
values={sortBy}
title="Sort by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
showSearch={false}
/>
<FilterButton
icon="sort"
showSearch={false}
collectionId={collectionId}
queryKey="orderByFilter"
queryFn={async () => {
return sortOrderOptions;
}}
set={(value) => setSortOrder(value, collectionId)}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
</View>
</ScrollView>
{!type && isFetching && (
<Loader
style={{
marginTop: 300,
}}
/>
)}
</View>
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
{flatData.map(
(item, index) =>
item && (
<TouchableItemRouter
key={`${item.Id}`}
style={{
width: "32%",
marginBottom: 4,
}}
item={item}
className={`
`}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)
)}
{flatData.length % 3 !== 0 && (
<View
style={{
width: "33%",
}}
></View>
)}
</View>
</View>
</ScrollView>
);
};

View File

@@ -15,6 +15,12 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
@@ -29,9 +35,13 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
const page: React.FC = () => {
@@ -45,6 +55,13 @@ const page: React.FC = () => {
const castDevice = useCastDevice();
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const client = useRemoteMediaClient();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
@@ -124,6 +141,47 @@ const page: React.FC = () => {
staleTime: 0,
});
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCurrentlyPlying({
item,
playbackUrl,
});
setPlaying(true);
setShowCurrentlyPlayingBar(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setTimeout(() => {
setFullscreen(true);
}, 100);
}
}
},
[playbackUrl, item, settings]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
@@ -194,7 +252,7 @@ const page: React.FC = () => {
<View className="flex flex-row justify-between items-center mb-2">
{playbackUrl ? (
<DownloadItem item={item} />
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}

View File

@@ -43,7 +43,7 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item]
[item],
);
const logoUrl = useMemo(
@@ -52,7 +52,7 @@ const page: React.FC = () => {
api,
item,
}),
[item]
[item],
);
if (!item || !backdropUrl) return null;
@@ -87,7 +87,7 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col pt-4">
<View className="flex flex-col pt-4 pb-24">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>

View File

@@ -2,6 +2,10 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import {
currentlyPlayingItemAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
@@ -11,7 +15,6 @@ import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { SimilarItems } from "@/components/SimilarItems";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
@@ -38,7 +41,7 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { setCurrentlyPlayingState } = usePlayback();
const [, setPlaying] = useAtom(playingAtom);
const castDevice = useCastDevice();
const navigation = useNavigation();
@@ -137,6 +140,7 @@ const page: React.FC = () => {
staleTime: 0,
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const onPressPlay = useCallback(
@@ -163,10 +167,11 @@ const page: React.FC = () => {
}
});
} else {
setCurrentlyPlayingState({
setCp({
item,
url: playbackUrl,
playbackUrl,
});
setPlaying(true);
}
},
[playbackUrl, item]
@@ -219,7 +224,7 @@ const page: React.FC = () => {
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} />
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
@@ -244,7 +249,12 @@ const page: React.FC = () => {
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton item={item} className="grow" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>

View File

@@ -118,13 +118,6 @@ function Layout() {
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/actors/[actorId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{

View File

@@ -22,12 +22,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
const audioStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item]
[item],
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected]
[audioStreams, selected],
);
useEffect(() => {
@@ -42,7 +42,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>

View File

@@ -52,7 +52,7 @@ export const BitrateSelector: React.FC<Props> = ({
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>

View File

@@ -9,12 +9,10 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
width?: number;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
width = 176,
}) => {
const [api] = useAtom(apiAtom);
@@ -23,8 +21,8 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
getPrimaryImageUrl({
api,
item,
quality: 80,
width: 300,
quality: 90,
width: 176 * 2,
}),
[item]
);
@@ -35,21 +33,11 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
if (!url)
return (
<View
className="aspect-video border border-neutral-800"
style={{
width,
}}
></View>
<View className="w-44 aspect-video border border-neutral-800"></View>
);
return (
<View
style={{
width,
}}
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
>
<View className="w-44 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<Image
key={item.Id}
id={item.Id}

View File

@@ -7,7 +7,7 @@ import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
@@ -17,14 +17,6 @@ import Animated, {
import Video from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import * as FileSystem from "expo-file-system";
import {
FFmpegKit,
FFmpegKitConfig,
FFmpegSession,
ReturnCode,
} from "ffmpeg-kit-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments();
@@ -71,106 +63,6 @@ export const CurrentlyPlayingBar: React.FC = () => {
};
});
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [ffmpegSession, setFfmpegSession] = useState<FFmpegSession | null>(
null
);
const startStreamingTranscode = async (inputUrl: string) => {
const outputDir = `${FileSystem.cacheDirectory}stream_${Date.now()}`;
const manifestPath = `${outputDir}/stream.m3u8`;
// Ensure the output directory exists
await FileSystem.makeDirectoryAsync(outputDir, { intermediates: true });
// Base FFmpeg command
let ffmpegCommand = `-i "${inputUrl}" `;
// Add hardware acceleration based on platform
if (Platform.OS === "android") {
ffmpegCommand += "-c:v h264_mediacodec "; // Hardware acceleration for Android
} else if (Platform.OS === "ios") {
ffmpegCommand += "-c:v h264_videotoolbox "; // Hardware acceleration for iOS
} else {
ffmpegCommand += "-c:v libx264 "; // Fallback to software encoding
}
// Complete the command
ffmpegCommand += `-c:a aac -f hls -hls_time 4 -hls_list_size 5 -hls_flags delete_segments "${manifestPath}"`;
console.log("FFmpeg command:", ffmpegCommand);
// Start FFmpeg process and return the session
return FFmpegKit.executeAsync(ffmpegCommand);
};
useEffect(() => {
const prepareStream = async () => {
if (currentlyPlaying?.url) {
try {
// Check if we already have a stream for this URL
const existingStream = await AsyncStorage.getItem(
currentlyPlaying.url
);
if (existingStream) {
setStreamUrl(existingStream);
} else {
const session = await startStreamingTranscode(currentlyPlaying.url);
setFfmpegSession(session);
const returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
console.log("Transcoding completed successfully");
const outputDir = `${
FileSystem.cacheDirectory
}stream_${Date.now()}`;
const manifestPath = `${outputDir}/stream.m3u8`;
setStreamUrl(manifestPath);
// Store the stream URL
await AsyncStorage.setItem(currentlyPlaying.url, manifestPath);
} else {
console.error("Transcoding failed");
// Handle failure (e.g., retry or show error message)
}
}
} catch (error) {
console.error("Error preparing stream:", error);
}
}
};
prepareStream();
return () => {
// Cleanup: cancel FFmpeg session when component unmounts
if (ffmpegSession) {
ffmpegSession.cancel();
}
};
}, [currentlyPlaying?.url]);
// Cleanup function
useEffect(() => {
return () => {
const cleanup = async () => {
if (streamUrl) {
try {
// Remove the stream URL from AsyncStorage
await AsyncStorage.removeItem(currentlyPlaying?.url || "");
// Delete the stream files
await FileSystem.deleteAsync(streamUrl.replace("file://", ""), {
idempotent: true,
});
} catch (error) {
console.error("Error cleaning up stream:", error);
}
}
};
cleanup();
};
}, [streamUrl, currentlyPlaying?.url]);
useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) {
// Tab screen - i.e. home
@@ -244,7 +136,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
`}
>
{streamUrl && (
{currentlyPlaying?.url && (
<Video
ref={videoRef}
allowsExternalPlayback
@@ -270,7 +162,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
fontSize: 16,
}}
source={{
uri: streamUrl,
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),

View File

@@ -4,132 +4,37 @@ import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
import ProgressCircle from "./ProgressCircle";
import { DownloadQuality, useSettings } from "@/utils/atoms/settings";
import { useCallback } from "react";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
interface DownloadProps extends TouchableOpacityProps {
type DownloadProps = {
item: BaseItemDto;
}
playbackUrl: string;
};
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
export const DownloadItem: React.FC<DownloadProps> = ({
item,
playbackUrl,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { startRemuxing } = useRemuxHlsToMp4(item);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
});
const initiateDownload = useCallback(
async (qualitySetting: DownloadQuality) => {
if (!api || !user?.Id || !item.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
let maxStreamingBitrate: number | undefined = undefined;
if (qualitySetting === "high") {
maxStreamingBitrate = 8000000;
} else if (qualitySetting === "low") {
maxStreamingBitrate = 2000000;
}
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
UserId: user.Id,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: 0,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
MediaSourceId: item.Id,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
let url: string | undefined = undefined;
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (!mediaSource) {
throw new Error("No media source");
}
if (mediaSource.SupportsDirectPlay) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: user.Id,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
url = `${api.basePath}/Audio/${
item.Id
}/universal?${searchParams.toString()}`;
}
}
if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
} else {
throw new Error("No transcoding url");
}
return await startRemuxing(url);
},
[api, item, startRemuxing, user?.Id]
);
const { data: downloaded, isFetching } = useQuery({
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
@@ -143,7 +48,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
enabled: !!item.Id,
});
if (isFetching) {
if (isLoading || isLoadingDownloaded) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Loader />
@@ -151,13 +56,20 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
);
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
}
if (process && process?.item.Id === item.Id) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
{process.progress === 0 ? (
@@ -184,7 +96,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="hourglass" size={24} color="white" />
@@ -199,7 +110,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download" size={26} color="#9333ea" />
@@ -213,16 +123,11 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
// await startRemuxing(playbackUrl);
if (!settings?.downloadQuality?.value) {
throw new Error("No download quality selected");
}
await initiateDownload(settings?.downloadQuality?.value);
await startRemuxing();
},
item,
});
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download-outline" size={26} color="white" />

View File

@@ -5,32 +5,26 @@ import { useState } from "react";
interface Props extends ViewProps {
text?: string | null;
characterLimit?: number;
}
export const OverviewText: React.FC<Props> = ({
text,
characterLimit = 140,
...props
}) => {
const [limit, setLimit] = useState(characterLimit);
const LIMIT = 140;
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
const [limit, setLimit] = useState(LIMIT);
if (!text) return null;
if (text.length > characterLimit)
if (text.length > LIMIT)
return (
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit
)
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
}
{...props}
>
<View {...props} className="">
<Text>{tc(text, limit)}</Text>
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? "Show more" : "Show less"}
{limit === LIMIT ? "Show more" : "Show less"}
</Text>
</View>
</TouchableOpacity>

View File

@@ -18,7 +18,7 @@ interface Props extends React.ComponentProps<typeof Button> {
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
const onPress = async () => {
if (!url || !item) return;

View File

@@ -22,14 +22,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
const subtitleStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle"
(x) => x.Type === "Subtitle",
) ?? [],
[item]
[item],
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected]
[subtitleStreams, selected],
);
useEffect(() => {
@@ -50,7 +50,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 13)

View File

@@ -1,6 +1,5 @@
import { FlashList, FlashListProps } from "@shopify/flash-list";
import React, { useEffect } from "react";
import { View, ViewStyle } from "react-native";
import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -9,13 +8,7 @@ import Animated, {
import { Loader } from "../Loader";
import { Text } from "./Text";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface HorizontalScrollProps<T>
extends PartialExcept<
Omit<FlashListProps<T>, "renderItem">,
"estimatedItemSize"
> {
interface HorizontalScrollProps<T> extends ScrollViewProps {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
containerStyle?: ViewStyle;
@@ -65,31 +58,31 @@ export function HorizontalScroll<T>({
);
}
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
<View className="mr-2">
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
);
return (
<Animated.View style={[containerStyle, animatedStyle1]}>
<FlashList
data={data}
renderItem={renderFlashListItem}
horizontal
estimatedItemSize={100}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
...contentContainerStyle,
}}
ListEmptyComponent={() => (
<ScrollView
horizontal
style={containerStyle}
contentContainerStyle={contentContainerStyle}
showsHorizontalScrollIndicator={false}
{...props}
>
<Animated.View
className={`
flex flex-row px-4
`}
style={[animatedStyle1]}
>
{data.map((item, index) => (
<View className="mr-2" key={index}>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
))}
{data.length === 0 && (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
</View>
)}
{...props}
/>
</Animated.View>
</Animated.View>
</ScrollView>
);
}

View File

@@ -1,13 +1,11 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import React, { useEffect } from "react";
import {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { FlashList, FlashListProps } from "@shopify/flash-list";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useEffect, useMemo } from "react";
import { View, ViewStyle } from "react-native";
NativeScrollEvent,
ScrollView,
ScrollViewProps,
View,
ViewStyle,
} from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -15,9 +13,16 @@ import Animated, {
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
import { useInfiniteQuery } from "@tanstack/react-query";
import {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useNavigation } from "expo-router";
import { useAtom } from "jotai";
interface HorizontalScrollProps
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
interface HorizontalScrollProps extends ScrollViewProps {
queryFn: ({
pageParam,
}: {
@@ -33,6 +38,18 @@ interface HorizontalScrollProps
loading?: boolean;
}
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 50;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
export function InfiniteHorizontalScroll({
queryFn,
queryKey,
@@ -47,6 +64,7 @@ export function InfiniteHorizontalScroll({
}: HorizontalScrollProps): React.ReactElement {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const animatedOpacity = useSharedValue(0);
const animatedStyle1 = useAnimatedStyle(() => {
@@ -55,7 +73,7 @@ export function InfiniteHorizontalScroll({
};
});
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam: (lastPage, pages) => {
@@ -82,13 +100,6 @@ export function InfiniteHorizontalScroll({
enabled: !!api && !!user?.Id,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
}, [data]);
useEffect(() => {
if (data) {
animatedOpacity.value = 1;
@@ -113,34 +124,41 @@ export function InfiniteHorizontalScroll({
}
return (
<Animated.View style={[containerStyle, animatedStyle1]}>
<FlashList
data={flatData}
renderItem={({ item, index }) => (
<View className="mr-2">
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
)}
estimatedItemSize={height}
horizontal
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
contentContainerStyle={{
paddingHorizontal: 16,
...contentContainerStyle,
}}
showsHorizontalScrollIndicator={false}
ListEmptyComponent={
<ScrollView
horizontal
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
style={containerStyle}
contentContainerStyle={contentContainerStyle}
showsHorizontalScrollIndicator={false}
{...props}
>
<Animated.View
className={`
flex flex-row px-4
`}
style={[animatedStyle1]}
>
{data?.pages
.flatMap((page) => page?.Items)
.map(
(item, index) =>
item && (
<View className="mr-2" key={index}>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
)
)}
{data?.pages.flatMap((page) => page?.Items).length === 0 && (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
</View>
}
{...props}
/>
</Animated.View>
)}
</Animated.View>
</ScrollView>
);
}

View File

@@ -45,10 +45,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
router.push(`/artists/${item.Id}/page`);
return;
}
if (item.Type === "Person") {
router.push(`/actors/${item.Id}`);
return;
}
if (item.Type === "BoxSet") {
router.push(`/collections/${item.Id}`);

View File

@@ -1,29 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className="flex flex-col"
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className="w-full bg-neutral-800 mb-2 rounded-lg"
></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
<View className="h-2 bg-neutral-800 rounded-full mb-2 w-1/2"></View>
</View>
);
};

View File

@@ -9,7 +9,11 @@ import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "@/utils/atoms/playState";
interface EpisodeCardProps {
item: BaseItemDto;
@@ -22,15 +26,23 @@ interface EpisodeCardProps {
*/
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const [settings] = useSettings();
const { setCurrentlyPlayingState } = usePlayback();
/**
* Handles opening the file for playback.
*/
const handleOpenFile = useCallback(async () => {
setCurrentlyPlayingState({
setCurrentlyPlaying({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, setCurrentlyPlayingState]);
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true)
setFullscreen(true);
}, [item, setCurrentlyPlaying, settings]);
/**
* Handles deleting the file with haptic feedback.

View File

@@ -11,7 +11,11 @@ import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import {
currentlyPlayingItemAtom,
playingAtom,
fullScreenAtom,
} from "@/utils/atoms/playState";
interface MovieCardProps {
item: BaseItemDto;
@@ -24,16 +28,25 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const [settings] = useSettings();
const { setCurrentlyPlayingState } = usePlayback();
/**
* Handles opening the file for playback.
*/
const handleOpenFile = useCallback(() => {
setCurrentlyPlayingState({
console.log("Open movie file", item.Name);
setCurrentlyPlaying({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, setCurrentlyPlayingState]);
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
}, [item, setCurrentlyPlaying, setPlaying, settings]);
/**
* Handles deleting the file with haptic feedback.

View File

@@ -1,7 +1,7 @@
import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
@@ -34,19 +34,16 @@ export const FilterButton = <T,>({
const [open, setOpen] = useState(false);
const { data: filters } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, collectionId],
queryKey: [queryKey, collectionId],
queryFn,
staleTime: 0,
enabled: !!collectionId && !!queryFn && !!queryKey,
});
if (filters?.length === 0) return null;
return (
<>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<TouchableOpacity onPress={() => setOpen(true)}>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
@@ -55,7 +52,6 @@ export const FilterButton = <T,>({
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
${filters?.length === 0 && "opacity-50"}
`}
{...props}
>

View File

@@ -173,7 +173,7 @@ export const FilterSheet = <T,>({
className="mb-4 flex flex-col rounded-xl overflow-hidden"
>
{renderData?.map((item, index) => (
<View key={index}>
<>
<TouchableOpacity
onPress={() => {
if (!values.includes(item)) {
@@ -183,6 +183,7 @@ export const FilterSheet = <T,>({
}, 250);
}
}}
key={`${index}`}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
<Text>{renderItemLabel(item)}</Text>
@@ -198,7 +199,7 @@ export const FilterSheet = <T,>({
}}
className="h-1 divide-neutral-700 "
></View>
</View>
</>
))}
</View>
{data.length < (_data?.length || 0) && (

View File

@@ -29,7 +29,7 @@ export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
setSelectedTags([]);
setSelectedYears([]);
}}
className="bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1"
className="bg-purple-600 rounded-full w-8 h-8 flex items-center justify-center"
{...props}
>
<Ionicons name="close" size={20} color="white" />

View File

@@ -1,93 +0,0 @@
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { TouchableOpacity, View, ViewProps } from "react-native";
import {
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
} from "@/utils/atoms/filters";
interface Props extends ViewProps {
title: string;
}
export const SortButton: React.FC<Props> = ({ title, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<View
className={`
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
`}
{...props}
>
<Text>Sort by</Text>
<Ionicons
name="filter"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</View>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
{sortOptions?.map((g) => (
<DropdownMenu.CheckboxItem
value={sortBy.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortBy(g);
} else {
setSortBy(sortOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
<DropdownMenu.Separator />
<DropdownMenu.Group>
{sortOrderOptions.map((g) => (
<DropdownMenu.CheckboxItem
value={sortOrder.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortOrder(g);
} else {
setSortOrder(sortOrderOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -93,7 +93,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
<View className="flex flex-col items-center" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={3000}
autoPlayInterval={2000}
loop={true}
ref={ref}
width={width}
@@ -123,16 +123,14 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const screenWidth = Dimensions.get("screen").width;
const uri = useMemo(() => {
if (!api) return null;
return getBackdropUrl({
api,
item,
quality: 70,
width: Math.floor(screenWidth * 0.8 * 2),
quality: 90,
width: 1000,
});
}, [api, item]);

View File

@@ -4,14 +4,16 @@ import {
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useCallback } from "react";
import { View, ViewProps } from "react-native";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { ScrollingCollectionList } from "../home/ScrollingCollectionList";
import { Text } from "../common/Text";
import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
import MoviePoster from "../posters/MoviePoster";
import { useCallback } from "react";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
collection: BaseItemDto;
@@ -33,7 +35,7 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
userId: user.Id,
parentId: collection.Id,
startIndex: pageParam,
limit: 8,
limit: 10,
});
return response.data;

View File

@@ -10,8 +10,12 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<View className="flex flex-row items-center self-center px-4" {...props}>
<Text className="text-center font-bold text-2xl mr-2">{item?.Name}</Text>
</View>
<>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
</>
);
};

View File

@@ -1,6 +1,9 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import ArtistPoster from "../ArtistPoster";
import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time";
import { useRouter } from "expo-router";
import { View, ViewProps } from "react-native";
import { SongsListItem } from "./SongsListItem";
interface Props extends ViewProps {

View File

@@ -1,20 +1,27 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import index from "@/app/(auth)/(tabs)/home";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { router } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
import { useActionSheet } from "@expo/react-native-action-sheet";
import ios from "@/utils/profiles/ios";
interface Props extends TouchableOpacityProps {
collectionId: string;
@@ -35,12 +42,12 @@ export const SongsListItem: React.FC<Props> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const { setCurrentlyPlayingState } = usePlayback();
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
@@ -66,7 +73,7 @@ export const SongsListItem: React.FC<Props> = ({
case cancelButtonIndex:
break;
}
}
},
);
};
@@ -111,10 +118,11 @@ export const SongsListItem: React.FC<Props> = ({
}
});
} else {
setCurrentlyPlayingState({
setCp({
item,
url,
playbackUrl: url,
});
setPlaying(true);
}
};

View File

@@ -36,7 +36,7 @@ const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
if (!item && id)
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}

View File

@@ -29,7 +29,7 @@ const ArtistPoster: React.FC<ArtistPosterProps> = ({
if (!url)
return (
<View
className="rounded-lg overflow-hidden border border-neutral-900"
className="rounded-md overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}

View File

@@ -23,7 +23,6 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
getPrimaryImageUrl({
api,
item,
width: 300,
}),
[item]
);
@@ -38,7 +37,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,

View File

@@ -28,7 +28,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
);
return (
<View className="rounded-lg overflow-hidden border border-neutral-900">
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}

View File

@@ -24,7 +24,7 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
);
return (
<View className="rounded-lg overflow-hidden border border-neutral-900">
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={
blurhash

View File

@@ -30,7 +30,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,

View File

@@ -11,13 +11,10 @@ import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { router, usePathname } from "expo-router";
import { useSettings } from "@/utils/atoms/settings";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
const [settings] = useSettings();
const pathname = usePathname();
return (
@@ -28,7 +25,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${item.Id}`);
router.push(`/search?q=${item.Name}&prev=${pathname}`);
}}
key={item.Id}
className="flex flex-col w-32"

View File

@@ -1,15 +1,17 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { nextUp } from "@/utils/jellyfin/tvshows/nextUp";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -24,7 +26,6 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
userId: user?.Id,
seriesId,
fields: ["MediaSourceCount"],
limit: 10,
})
).data.Items;
},

View File

@@ -1,33 +1,26 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
type Props = {
item: BaseItemDto;
};
type SeasonIndexState = {
[seriesId: string]: number;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const seasonIndexAtom = atom<number>(1);
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const seasonIndex = seasonIndexState[item.Id ?? ""];
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
const router = useRouter();
@@ -47,7 +40,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
return response.data.Items;
@@ -55,24 +48,13 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id,
});
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
const firstSeason = seasons[0];
if (firstSeason.IndexNumber !== undefined) {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: firstSeason.IndexNumber,
}));
}
}
}, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
[seasons, seasonIndex],
);
const { data: episodes, isFetching } = useQuery({
const { data: episodes } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) return [];
@@ -88,7 +70,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
return response.data.Items as BaseItemDto[];
@@ -96,20 +78,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => {
if (episodes && episodes.length > 0) {
setNrOfEpisodes(episodes.length);
}
}, [episodes]);
return (
<View
style={{
minHeight: 144 * nrOfEpisodes,
}}
>
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
@@ -132,10 +102,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
setSeasonIndex(season.IndexNumber);
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
@@ -143,8 +110,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/* Old View. Might have a setting later to manually select view. */}
{/* {episodes && (
{episodes && (
<View className="mt-4">
<HorizontalScroll<BaseItemDto>
data={episodes}
@@ -162,56 +128,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
)}
/>
</View>
)} */}
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View
style={{
minHeight: 144 * nrOfEpisodes,
}}
className="flex flex-col items-center justify-center"
>
<Loader />
</View>
) : (
episodes?.map((e: BaseItemDto) => (
<TouchableOpacity
key={e.Id}
onPress={() => {
router.push(`/(auth)/items/${e.Id}`);
}}
className="flex flex-col mb-4"
>
<View className="flex flex-row items-center mb-2">
<View className="w-32 aspect-video overflow-hidden mr-2">
<ContinueWatchingPoster item={e} width={128} />
</View>
<View className="shrink">
<Text numberOfLines={2} className="">
{e.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(e.RunTimeTicks)}
</Text>
</View>
<View className="self-start ml-auto">
<DownloadItem item={e} />
</View>
</View>
<Text
numberOfLines={3}
className="text-xs text-neutral-500 shrink"
>
{e.Overview}
</Text>
</TouchableOpacity>
))
)}
</View>
)}
</View>
);
};

View File

@@ -1,5 +1,5 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
import { useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
@@ -58,46 +58,6 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Download quality</Text>
<Text className="text-xs opacity-50">
Choose the search engine you want to use.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.downloadQuality?.label}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Quality</DropdownMenu.Label>
{DownloadOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
onSelect={() => {
updateSettings({ downloadQuality: option });
}}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Start videos in fullscreen</Text>
@@ -113,23 +73,6 @@ export const SettingToggles: React.FC = () => {
}
/>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This requries
VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings?.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
@@ -214,6 +157,22 @@ export const SettingToggles: React.FC = () => {
/>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This requries
VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings?.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

@@ -21,13 +21,13 @@
}
},
"production": {
"channel": "0.8.2",
"channel": "0.7.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.8.2",
"channel": "0.7.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -26,12 +26,11 @@ export const useFiles = () => {
fileNames.map((item) =>
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
idempotent: true,
})
)
}),
),
);
await AsyncStorage.removeItem("downloaded_files");
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} catch (error) {
console.error("Failed to delete all files:", error);
}
@@ -50,7 +49,7 @@ export const useFiles = () => {
try {
await FileSystem.deleteAsync(
`${FileSystem.documentDirectory}/${id}.mp4`,
{ idempotent: true }
{ idempotent: true },
);
const currentFiles = await getDownloadedFiles();
@@ -58,7 +57,7 @@ export const useFiles = () => {
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles)
JSON.stringify(updatedFiles),
);
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });

View File

@@ -6,8 +6,6 @@ import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log";
import { useQueryClient } from "@tanstack/react-query";
import { Platform } from "react-native";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -16,9 +14,8 @@ import { Platform } from "react-native";
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
const queryClient = useQueryClient();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
@@ -26,104 +23,87 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(
async (url: string) => {
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
);
// let command: string | null = null;
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
// if (Platform.OS === "android") {
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_mediacodec -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// } else if (Platform.OS === "ios") {
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_videotoolbox -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// } else {
// throw new Error("Unsupported platform");
// }
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
);
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev
);
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
);
resolve();
}
setProgress(null);
} catch (error) {
reject(error);
}
});
});
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev,
);
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
},
[output, item, setProgress]
);
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
resolve();
}
setProgress(null);
} catch (error) {
reject(error);
}
});
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
}, [output, item, command, setProgress]);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProgress(null);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
);
}, [item.Name, setProgress]);
@@ -138,7 +118,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
const updatedFiles = [
...currentFiles.filter((i) => i.Id !== item.Id),
@@ -146,13 +126,13 @@ async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles)
JSON.stringify(updatedFiles),
);
} catch (error) {
console.error("Error updating downloaded files:", error);
writeToLog(
"ERROR",
`Failed to update downloaded files for item: ${item.Name}`
`Failed to update downloaded files for item: ${item.Name}`,
);
}
}

View File

@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.8.2" },
clientInfo: { name: "Streamyfin", version: "0.7.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);

View File

@@ -179,7 +179,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
}, []);
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
if (!deviceId || !api || !user) return;
const url = `wss://${api?.basePath
.replace("https://", "")

View File

@@ -5,17 +5,16 @@ import {
SortOrder,
} from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
export const sortOptions: {
export const sortByOptions: {
key: ItemSortBy;
value: string;
}[] = [
{ key: "SortName", value: "Name" },
{ key: "CommunityRating", value: "Community Rating" },
{ key: "CriticRating", value: "Critics Rating" },
{ key: "DateCreated", value: "Date Added" },
// Only works for shows (last episode added) keeping for future ref.
// { key: "DateLastContentAdded", value: "Content Added" },
{ key: "DateLastContentAdded", value: "Content Added" },
{ key: "DatePlayed", value: "Date Played" },
{ key: "PlayCount", value: "Play Count" },
{ key: "ProductionYear", value: "Production Year" },
@@ -25,8 +24,7 @@ export const sortOptions: {
{ key: "StartDate", value: "Start Date" },
{ key: "IsUnplayed", value: "Is Unplayed" },
{ key: "IsPlayed", value: "Is Played" },
// Broken in JF
// { key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
@@ -41,10 +39,111 @@ export const sortOrderOptions: {
{ key: "Descending", value: "Descending" },
];
export const genreFilterAtom = atom<string[]>([]);
export const tagsFilterAtom = atom<string[]>([]);
export const yearFilterAtom = atom<string[]>([]);
export const sortByAtom = atom<[typeof sortOptions][number]>([sortOptions[0]]);
export const sortOrderAtom = atom<[typeof sortOrderOptions][number]>([
sortOrderOptions[0],
]);
// Define the keys for our preferences
type PreferenceKey =
| "genreFilter"
| "tagsFilter"
| "yearFilter"
| "sortBy"
| "sortOrder";
// Define the type for a single collection's preferences
type CollectionPreference = {
genreFilter: string[];
tagsFilter: string[];
yearFilter: string[];
sortBy: [typeof sortByOptions][number];
sortOrder: [typeof sortOrderOptions][number];
};
// Define the type for all sort preferences
type SortPreference = {
[collectionId: string]: CollectionPreference;
};
// Create a base atom with storage
const baseSortPreferenceAtom = atomWithStorage<SortPreference>(
"sortPreferences",
{}
);
// Create a derived atom with logging
export const sortPreferenceAtom = atom(
(get) => {
const value = get(baseSortPreferenceAtom);
console.log("Getting sortPreferences:", value);
return value;
},
(get, set, newValue: SortPreference) => {
console.log("Setting sortPreferences:", newValue);
set(baseSortPreferenceAtom, newValue);
}
);
export const currentCollectionIdAtom = atomWithStorage<string | null>(
"currentCollectionId",
null
);
// Helper function to create an atom with custom getter and setter
const createFilterAtom = <T extends CollectionPreference[PreferenceKey]>(
key: PreferenceKey,
initialValue: T
) => {
const baseAtom = atom<T>(initialValue);
return atom(
(get): T => {
const preferences = get(sortPreferenceAtom);
const currentCollectionId = get(currentCollectionIdAtom);
if (currentCollectionId && preferences[currentCollectionId]) {
const preferenceValue = preferences[currentCollectionId][key];
// Ensure the returned value matches the expected type T
if (Array.isArray(initialValue) && Array.isArray(preferenceValue)) {
return preferenceValue as T;
} else if (
typeof initialValue === "object" &&
typeof preferenceValue === "object"
) {
return preferenceValue as T;
} else if (typeof initialValue === typeof preferenceValue) {
return preferenceValue as T;
}
}
return get(baseAtom);
},
(get, set, newValue: T, collectionId: string) => {
set(baseAtom, newValue);
const preferences = get(sortPreferenceAtom);
console.log("Set", preferences);
set(sortPreferenceAtom, {
...preferences,
[collectionId]: {
...preferences[collectionId],
[key]: newValue,
},
});
}
);
};
type SortByOption = ItemSortBy | { key: ItemSortBy; value: string };
type SortOrderOption = SortOrder | { key: SortOrder; value: string };
function getSortKey(
option: SortByOption | SortOrderOption
): ItemSortBy | SortOrder {
return typeof option === "string" ? option : option.key;
}
export const genreFilterAtom = createFilterAtom<string[]>("genreFilter", []);
export const tagsFilterAtom = createFilterAtom<string[]>("tagsFilter", []);
export const yearFilterAtom = createFilterAtom<string[]>("yearFilter", []);
export const sortByAtom = createFilterAtom<[typeof sortByOptions][number]>(
"sortBy",
[sortByOptions[0]]
);
export const sortOrderAtom = createFilterAtom<
[typeof sortOrderOptions][number]
>("sortOrder", [sortOrderOptions[0]]);

10
utils/atoms/playState.ts Normal file
View File

@@ -0,0 +1,10 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom } from "jotai";
export const playingAtom = atom(false);
export const fullScreenAtom = atom(false);
export const showCurrentlyPlayingBarAtom = atom(false);
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);

View File

@@ -2,28 +2,6 @@ import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
export type DownloadQuality = "original" | "high" | "low";
export type DownloadOption = {
label: string;
value: DownloadQuality;
};
export const DownloadOptions: DownloadOption[] = [
{
label: "Original quality",
value: "original",
},
{
label: "High quality",
value: "high",
},
{
label: "Small file size",
value: "low",
},
];
type Settings = {
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
@@ -35,7 +13,6 @@ type Settings = {
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
};
/**
@@ -46,31 +23,23 @@ type Settings = {
*
*/
// Utility function to load settings from AsyncStorage
const loadSettings = async (): Promise<Settings> => {
const defaultValues: Settings = {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: false,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
};
try {
const jsonValue = await AsyncStorage.getItem("settings");
const loadedValues: Partial<Settings> =
jsonValue != null ? JSON.parse(jsonValue) : {};
return { ...defaultValues, ...loadedValues };
} catch (error) {
console.error("Failed to load settings:", error);
return defaultValues;
}
const jsonValue = await AsyncStorage.getItem("settings");
return jsonValue != null
? JSON.parse(jsonValue)
: {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: false,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
};
};
// Utility function to save settings to AsyncStorage

View File

@@ -15,8 +15,8 @@ import { isBaseItemDto } from "../jellyfin";
export const getPrimaryImageUrl = ({
api,
item,
quality = 80,
width = 400,
quality = 90,
width = 500,
}: {
api?: Api | null;
item?: BaseItemDto | BaseItemPerson | null;