Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
752cb1cdc6 wip 2024-08-18 17:10:31 +02:00
46 changed files with 899 additions and 1407 deletions

View File

@@ -26,30 +26,21 @@ 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.
### 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.
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.
## 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.
## Get it now
<div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div>
<div style="display:flex;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
<img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
</a>
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
</a>
</div>
### Beta testing
@@ -59,6 +50,8 @@ Get the latest updates by using the TestFlight version of the app.
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
Or download the APKs here on GitHub for Android.
## 🚀 Getting Started
### Prerequisites
@@ -113,7 +106,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/zyGKHJZvv4)
If you have questions or need support, feel free to reach out:

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.6.2",
"version": "0.6.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -30,7 +30,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 17,
"versionCode": 18,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -97,17 +97,7 @@
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
],
[
"react-native-vlc-media-player",
{
"ios": {
"includeVLCKit": false // should be true if react-native version < 0.61
},
"android": {
"legacyJetifier": false // should be true if react-native version < 0.71
}
}
]
"expo-localization"
],
"experiments": {
"typedRoutes": true

View File

@@ -1,15 +1,15 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform, StyleSheet } from "react-native";
export default function TabLayout() {
const { t } = useTranslation();
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
@@ -53,7 +53,7 @@ export default function TabLayout() {
name="home"
options={{
headerShown: false,
title: "Home",
title: t("tabs.home"),
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "home" : "home-outline"}
@@ -66,17 +66,17 @@ export default function TabLayout() {
name="search"
options={{
headerShown: false,
title: "Search",
title: t("tabs.search"),
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />
),
}}
/>
<Tabs.Screen
name="libraries"
name="library"
options={{
headerShown: false,
title: "Library",
title: t("tabs.library"),
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}

View File

@@ -1,11 +1,13 @@
import { Chromecast } from "@/components/Chromecast";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
import { TouchableOpacity } from "react-native";
export default function IndexLayout() {
const router = useRouter();
const { t } = useTranslation();
return (
<Stack>
<Stack.Screen
@@ -13,7 +15,7 @@ export default function IndexLayout() {
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Home",
headerTitle: t("home.home"),
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,

View File

@@ -20,12 +20,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { RefreshControl, ScrollView, View } from "react-native";
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const { i18n, t } = useTranslation();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -216,9 +219,9 @@ export default function index() {
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-3xl font-bold mb-2">{t("home.noInternet")}</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
{t("home.noInternetMessage")}
</Text>
<View className="mt-4">
<Button
@@ -229,7 +232,7 @@ export default function index() {
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
{t("home.goToDownloads")}
</Button>
</View>
</View>
@@ -239,10 +242,8 @@ export default function index() {
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
<Text className="text-center opacity-70">{t("home.errorMessage")}</Text>
</View>
);
@@ -265,14 +266,14 @@ export default function index() {
<LargeMovieCarousel />
<ScrollingCollectionList
title="Continue Watching"
title={t("home.continueWatching")}
data={data}
loading={isLoading}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Next Up"
title={t("home.nextUp")}
data={nextUpData}
loading={isLoadingNextUp}
orientation="horizontal"
@@ -283,19 +284,19 @@ export default function index() {
))}
<ScrollingCollectionList
title="Recently Added in Movies"
title={t("home.recentlyAddedMovies")}
data={recentlyAddedInMovies}
loading={isLoadingRecentlyAddedMovies}
/>
<ScrollingCollectionList
title="Recently Added in TV-Shows"
title={t("home.recentlyAddedTVShows")}
data={recentlyAddedInTVShows}
loading={isLoadingRecentlyAddedTVShows}
/>
<ScrollingCollectionList
title="Suggestions"
title={t("home.suggestions")}
data={suggestions}
loading={isLoadingSuggestions}
orientation="horizontal"

View File

@@ -16,7 +16,7 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="[libraryId]"
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,

View File

@@ -19,11 +19,7 @@ import {
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
@@ -44,7 +40,7 @@ const isCloseToBottom = ({
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: string };
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -56,18 +52,18 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: library } = useQuery({
queryKey: ["library", libraryId],
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: libraryId,
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data;
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!libraryId,
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
@@ -77,11 +73,11 @@ const page: React.FC = () => {
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
if (!api || !collection) return null;
const includeItemTypes: BaseItemKind[] = [];
switch (library?.CollectionType) {
switch (collection?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
@@ -100,7 +96,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
parentId: collectionId,
limit: 66,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
@@ -120,8 +116,8 @@ const page: React.FC = () => {
[
api,
user?.Id,
libraryId,
library,
collectionId,
collection?.CollectionType,
selectedGenres,
selectedYears,
selectedTags,
@@ -133,7 +129,7 @@ const page: React.FC = () => {
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey: [
"library-items",
library,
collection,
selectedGenres,
selectedYears,
selectedTags,
@@ -162,7 +158,7 @@ const page: React.FC = () => {
}
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!library,
enabled: !!api && !!user?.Id && !!collection,
});
const type = useMemo(() => {
@@ -173,7 +169,7 @@ const page: React.FC = () => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
if (!library || !library.CollectionType) return null;
if (!collection || !collection.CollectionType) return null;
return (
<ScrollView
@@ -191,7 +187,7 @@ const page: React.FC = () => {
<View className="flex flex-row space-x-1 px-3">
<ResetFiltersButton />
<FilterButton
collectionId={libraryId}
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
@@ -200,7 +196,7 @@ const page: React.FC = () => {
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: libraryId,
parentId: collectionId,
});
return response.data.Genres || [];
}}
@@ -213,7 +209,7 @@ const page: React.FC = () => {
}
/>
<FilterButton
collectionId={libraryId}
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
@@ -222,7 +218,7 @@ const page: React.FC = () => {
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: libraryId,
parentId: collectionId,
});
return response.data.Tags || [];
}}
@@ -235,7 +231,7 @@ const page: React.FC = () => {
}
/>
<FilterButton
collectionId={libraryId}
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
@@ -244,7 +240,7 @@ const page: React.FC = () => {
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: libraryId,
parentId: collectionId,
});
return (
response.data.Years?.sort((a, b) => b - a).map((y) =>
@@ -262,7 +258,7 @@ const page: React.FC = () => {
/>
<FilterButton
icon="sort"
collectionId={libraryId}
collectionId={collectionId}
queryKey="sortByFilter"
queryFn={async () => {
return sortOptions;
@@ -280,7 +276,7 @@ const page: React.FC = () => {
<FilterButton
icon="sort"
showSearch={false}
collectionId={libraryId}
collectionId={collectionId}
queryKey="orderByFilter"
queryFn={async () => {
return sortOrderOptions;
@@ -309,7 +305,7 @@ const page: React.FC = () => {
(item, index) =>
item && (
<TouchableItemRouter
key={`${item.Id}-${index}`}
key={`${item.Id}`}
style={{
width: "32%",
marginBottom: 4,

View File

@@ -49,7 +49,7 @@ export default function index() {
paddingBottom: 150,
}}
data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />}
renderItem={({ item }) => <CollectionCard collection={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => <View className="h-4" />}
estimatedItemSize={200}
@@ -58,10 +58,10 @@ export default function index() {
}
interface Props {
library: BaseItemDto;
collection: BaseItemDto;
}
const LibraryItemCard: React.FC<Props> = ({ library }) => {
const CollectionCard: React.FC<Props> = ({ collection }) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
@@ -70,9 +70,9 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
() =>
getPrimaryImageUrl({
api,
item: library,
item: collection,
}),
[library]
[collection]
);
if (!url) return null;
@@ -80,7 +80,7 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
return (
<TouchableOpacity
onPress={() => {
router.push(`/libraries/${library.Id}`);
router.push(`/library/collections/${collection.Id}`);
}}
>
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
@@ -96,7 +96,7 @@ const LibraryItemCard: React.FC<Props> = ({ library }) => {
}}
/>
<Text className="font-bold text-xl text-start px-4">
{library.Name}
{collection.Name}
</Text>
</View>
</TouchableOpacity>

View File

@@ -1,4 +1,3 @@
import { Button } from "@/components/Button";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
@@ -10,32 +9,13 @@ import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import {
Href,
router,
useLocalSearchParams,
useNavigation,
usePathname,
} from "expo-router";
import { router, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import React, { useLayoutEffect, useMemo, useState } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useDebounce } from "use-debounce";
@@ -49,10 +29,6 @@ const exampleSearches = [
];
export default function search() {
const params = useLocalSearchParams();
const { q, prev } = params as { q: string; prev: Href<string> };
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
@@ -60,131 +36,107 @@ export default function search() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const searchEngine = useMemo(() => {
return settings?.searchEngine || "Jellyfin";
}, [settings]);
useEffect(() => {
if (q && q.length > 0) setSearch(q);
}, [q]);
const searchFn = useCallback(
async ({
types,
query,
}: {
types: BaseItemKind[];
query: string;
}): Promise<BaseItemDto[]> => {
if (!api) return [];
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
});
return searchApi.data.SearchHints as BaseItemDto[];
} else {
const url = `${settings?.marlinServerUrl}/search?q=${encodeURIComponent(
query
)}&includeItemTypes=${types
.map((type) => encodeURIComponent(type))
.join("&includeItemTypes=")}`;
const response1 = await axios.get(url);
const ids = response1.data.ids;
if (!ids || !ids.length) return [];
const response2 = await getItemsApi(api).getItems({
ids,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
});
return response2.data.Items as BaseItemDto[];
}
},
[api, settings]
);
const navigation = useNavigation();
useLayoutEffect(() => {
if (Platform.OS === "ios")
navigation.setOptions({
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
onChangeText: (e: any) => setSearch(e.nativeEvent.text),
hideWhenScrolling: false,
autoFocus: true,
},
});
}, [navigation]);
const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Movie"],
}),
enabled: debouncedSearch.length > 0,
const { data: movies, isLoading: l1 } = useQuery({
queryKey: ["search-movies", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Movie"],
});
return searchApi.data.SearchHints;
},
});
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Series"],
}),
enabled: debouncedSearch.length > 0,
const { data: series, isLoading: l2 } = useQuery({
queryKey: ["search-series", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Series"],
});
return searchApi.data.SearchHints;
},
});
const { data: episodes, isFetching: l3 } = useQuery({
queryKey: ["search", "episodes", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Episode"],
}),
enabled: debouncedSearch.length > 0,
const { data: episodes, isLoading: l3 } = useQuery({
queryKey: ["search-episodes", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Episode"],
});
return searchApi.data.SearchHints;
},
});
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
const { data: artists, isLoading: l4 } = useQuery({
queryKey: ["search-artists", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["MusicArtist"],
});
return searchApi.data.SearchHints;
},
});
const { data: albums, isFetching: l5 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
const { data: albums, isLoading: l5 } = useQuery({
queryKey: ["search-albums", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["MusicAlbum"],
});
return searchApi.data.SearchHints;
},
});
const { data: songs, isFetching: l6 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
const { data: songs, isLoading: l6 } = useQuery({
queryKey: ["search-songs", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Audio"],
});
return searchApi.data.SearchHints;
},
});
const noResults = useMemo(() => {
@@ -221,13 +173,6 @@ export default function search() {
/>
</View>
)}
{!!q && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
Results for <Text className="text-purple-600">{q}</Text>
</Text>
</View>
)}
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
@@ -237,7 +182,7 @@ export default function search() {
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-32"
onPress={() => router.push(`/items/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
@@ -262,7 +207,7 @@ export default function search() {
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-28"
className="flex flex-col w-32"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
@@ -286,7 +231,7 @@ export default function search() {
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-44"
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -305,7 +250,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-32"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -324,7 +269,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-32"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -343,7 +288,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
className="flex flex-col w-32"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />

View File

@@ -1,46 +1,20 @@
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 ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
genreFilterAtom,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
BaseItemDtoQueryResult,
BaseItemDto,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 200;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
import { useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -48,294 +22,200 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
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);
useEffect(() => {
setSortBy([
{
key: "ProductionYear",
value: "Production Year",
},
]);
setSortOrder([
{
key: "Descending",
value: "Descending",
},
]);
}, []);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getUserLibraryApi(api).getItem({
itemId: collectionId,
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data;
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
}, [navigation, collection]);
const [startIndex, setStartIndex] = useState<number>(0);
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !collection) return null;
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const sortBy: ItemSortBy[] = [];
const includeItemTypes: BaseItemKind[] = [];
switch (collection?.CollectionType) {
case "movies":
sortBy.push("SortName", "ProductionYear");
break;
case "boxsets":
sortBy.push("IsFolder", "SortName");
break;
default:
sortBy.push("SortName");
break;
}
switch (collection?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
case "boxsets":
includeItemTypes.push("BoxSet");
break;
case "tvshows":
includeItemTypes.push("Series");
break;
case "music":
includeItemTypes.push("MusicAlbum");
break;
default:
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 18,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
limit: 100,
startIndex,
sortBy,
sortOrder: ["Ascending"],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
});
return response.data || null;
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
[
api,
user?.Id,
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]
);
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey: [
"collection-items",
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
],
queryFn: fetchItems,
getNextPageParam: (lastPage, pages) => {
if (
!lastPage?.Items ||
!lastPage?.TotalRecordCount ||
lastPage?.TotalRecordCount === 0
)
return undefined;
const totalItems = lastPage.TotalRecordCount;
const accumulatedItems = pages.reduce(
(acc, curr) => acc + (curr?.Items?.length || 0),
0
);
if (accumulatedItems < totalItems) {
return lastPage?.Items?.length * pages.length;
} else {
return undefined;
}
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
enabled: !!collection?.Id && !!api && !!user?.Id,
});
useEffect(() => {
console.log("Data: ", data);
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
const type = useMemo(() => {
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
}, [data]);
const flatData = useMemo(() => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
if (!collection) return null;
return (
<ScrollView
contentInsetAdjustmentBehavior="automatic"
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
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 || [];
<ScrollView>
<View>
<View className="px-4 mb-4">
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text>
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
{totalItems}
</Text>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
}}
set={setSelectedGenres}
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 || [];
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
}}
set={setSelectedTags}
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={setSelectedYears}
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 sortOptions;
}}
set={setSortBy}
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={setSortOrder}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
</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>
{isLoading ? (
<View className="my-12">
<Loader />
</View>
) : (
<View className="flex flex-row flex-wrap">
{data?.Items?.map((item: BaseItemDto, index: number) => (
<TouchableOpacity
style={{
maxWidth: "33%",
width: "100%",
padding: 10,
}}
key={index}
onPress={() => {
if (item?.Type === "Series") {
router.push(`/series/${item.Id}`);
} else if (item.IsFolder) {
router.push(`/collections/${item?.Id}`);
} else {
router.push(`/items/${item.Id}`);
}
}}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" ? (
<MoviePoster item={item} />
) : collection?.CollectionType === "music" ? (
<ArtistPoster item={item} />
) : (
<MoviePoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</View>
</TouchableOpacity>
))}
</View>
)}
</View>
{!isLoading && (
<View className="flex flex-row items-center space-x-2 justify-center mt-4 mb-12">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
}}
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
}}
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
};

View File

@@ -1,5 +1,10 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
@@ -15,12 +20,6 @@ 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";
@@ -56,7 +55,6 @@ const page: React.FC = () => {
const castDevice = useCastDevice();
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
@@ -170,12 +168,8 @@ const page: React.FC = () => {
playbackUrl,
});
setPlaying(true);
setShowCurrentlyPlayingBar(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setTimeout(() => {
setFullscreen(true);
}, 100);
setFullscreen(true);
}
}
},
@@ -280,7 +274,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} url={playbackUrl} className="grow" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>

View File

@@ -9,7 +9,6 @@ import { ScrollView, View } from "react-native";
import * as Haptics from "expo-haptics";
import { useFiles } from "@/hooks/useFiles";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
export default function settings() {
const { logout } = useJellyfin();
@@ -45,7 +44,7 @@ export default function settings() {
onPress={async () => {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
Haptics.NotificationFeedbackType.Success,
);
}}
>
@@ -56,7 +55,7 @@ export default function settings() {
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
Haptics.NotificationFeedbackType.Success,
);
}}
>

View File

@@ -1,22 +1,25 @@
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
import { Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useEffect, useRef, useState } from "react";
import "react-native-reanimated";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { useJobProcessor } from "@/utils/atoms/queue";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { useKeepAwake } from "expo-keep-awake";
import { useSettings } from "@/utils/atoms/settings";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { I18nextProvider, useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { getLocales } from "expo-localization";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -42,7 +45,9 @@ export default function RootLayout() {
return (
<JotaiProvider>
<Layout />
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</JotaiProvider>
);
}
@@ -52,6 +57,8 @@ function Layout() {
useKeepAwake();
const { i18n } = useTranslation();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
@@ -75,6 +82,12 @@ function Layout() {
);
}, [settings]);
useEffect(() => {
i18n.changeLanguage(
settings?.preferedLanguage || getLocales()[0].languageCode || "en"
);
}, [settings]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
@@ -82,101 +95,99 @@ function Layout() {
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>

View File

@@ -1,11 +1,13 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
KeyboardAvoidingView,
@@ -21,6 +23,7 @@ const CredentialsSchema = z.object({
});
const Login: React.FC = () => {
const { t, i18n } = useTranslation();
const { setServer, login, removeServer } = useJellyfin();
const [api] = useAtom(apiAtom);
@@ -72,7 +75,7 @@ const Login: React.FC = () => {
<View className="mb-4">
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
{t("server.server_label", { serverURL: api.basePath })}
</Text>
<Button
color="black"
@@ -89,17 +92,17 @@ const Login: React.FC = () => {
/>
}
>
Change server
{t("server.change_server")}
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-2xl font-bold">{t("login.login")}</Text>
<Text className="text-neutral-500">
Log in to any user account
{t("login.login_subtitle")}
</Text>
<Input
placeholder="Username"
placeholder={t("login.username_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
@@ -116,7 +119,7 @@ const Login: React.FC = () => {
<Input
className="mb-2"
placeholder="Password"
placeholder={t("login.password_placeholder")}
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
@@ -139,7 +142,7 @@ const Login: React.FC = () => {
loading={loading}
className="mt-auto mb-2"
>
Log in
{t("login.login_button")}
</Button>
</View>
</KeyboardAvoidingView>
@@ -158,10 +161,10 @@ const Login: React.FC = () => {
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
{t("server.connect_to_server")}
</Text>
<Input
placeholder="Server URL"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
@@ -170,12 +173,11 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
<Text className="opacity-30">{t("server.server_url_hint")}</Text>
<LanguageSwitcher />
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
Connect
{t("server.connect_button")}
</Button>
</View>
</KeyboardAvoidingView>

View File

@@ -1,40 +0,0 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_DE_RGB_blk_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M39.3926,14.69775H35.67092V8.731h.92676V13.8457H39.3926Z" style="fill: #fff"/>
<path d="M40.32912,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,40.32912,13.42432Zm2.89453-.38477v-.37646L42.124,12.7334c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,43.22365,13.03955Z" style="fill: #fff"/>
<path d="M45.27639,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074H48.6299v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C46,14.772,45.27639,13.87061,45.27639,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C46.64943,10.91846,46.19436,11.49707,46.19436,12.44434Z" style="fill: #fff"/>
<path d="M54.74709,13.48193a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422A2.07685,2.07685,0,0,1,52.792,10.10791c1.25293,0,2.00879.856,2.00879,2.27V12.688H51.62111v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,51.62111,12.03076Z" style="fill: #fff"/>
<path d="M55.99416,10.19482h.85547v.71533H56.916a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M63.51955,8.86328a.57572.57572,0,1,1,.5752.5415A.54735.54735,0,0,1,63.51955,8.86328Zm.13281,1.33154h.88477v4.50293h-.88477Z" style="fill: #fff"/>
<path d="M65.97121,10.19482h.85547v.72363h.06641a1.36385,1.36385,0,0,1,2.49316,0h.07031a1.46325,1.46325,0,0,1,1.36914-.81055,1.33821,1.33821,0,0,1,1.43848,1.48828v3.10156h-.88867V11.82813c0-.60791-.29-.90576-.873-.90576a.91167.91167,0,0,0-.9502.94287v2.83252h-.873V11.74121a.78468.78468,0,0,0-.86816-.81885.96854.96854,0,0,0-.95117,1.02148v2.75391h-.88867Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.1 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -21,23 +21,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
getPrimaryImageUrl({
api,
item,
quality: 90,
width: 176 * 2,
quality: 70,
width: 300,
}),
[item]
[item],
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
item.UserData?.PlayedPercentage || 0,
);
if (!url)
return (
<View className="w-44 aspect-video border border-neutral-800"></View>
<View className="w-48 aspect-video border border-neutral-800"></View>
);
return (
<View className="w-44 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<Image
key={item.Id}
id={item.Id}

View File

@@ -1,40 +1,50 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Video from "react-native-video";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { VLCPlayer, VlCPlayerView } from "react-native-vlc-media-player";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);
export const playingAtom = atom(false);
export const fullScreenAtom = atom(false);
export const CurrentlyPlayingBar: React.FC = () => {
const queryClient = useQueryClient();
const segments = useSegments();
const {
currentlyPlaying,
pauseVideo,
playVideo,
setCurrentlyPlayingState,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
} = usePlayback();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [playing, setPlaying] = useAtom(playingAtom);
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
currentlyPlayingItemAtom
);
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
const videoRef = useRef<VideoRef | null>(null);
const [progress, setProgress] = useState(0);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
@@ -82,28 +92,108 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
}, [segments]);
const { data: item } = useQuery({
queryKey: ["item", currentlyPlaying?.item.Id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: currentlyPlaying?.item.Id,
}),
enabled: !!currentlyPlaying?.item.Id && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!currentTime ||
!sessionData?.PlaySessionId ||
!playing ||
!api ||
!currentlyPlaying?.item.Id
)
return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
);
useEffect(() => {
if (!item || !api) return;
if (playing) {
videoRef.current?.resume();
} else {
videoRef.current?.pause();
if (progress > 0 && sessionData?.PlaySessionId)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}
}, [playing, progress, item, sessionData]);
useEffect(() => {
if (fullScreen === true) {
videoRef.current?.presentFullscreenPlayer();
} else {
videoRef.current?.dismissFullscreenPlayer();
}
}, [fullScreen]);
const startPosition = useMemo(
() =>
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
? Math.round(
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
)
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[currentlyPlaying?.item]
[item]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item: currentlyPlaying?.item,
item,
quality: 70,
width: 200,
}),
[currentlyPlaying?.item, api]
[item]
);
if (!api || !currentlyPlaying) return null;
if (!currentlyPlaying || !api) return null;
return (
<Animated.View
@@ -130,129 +220,113 @@ export const CurrentlyPlayingBar: React.FC = () => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${
currentlyPlaying.item?.Type === "Audio"
? "aspect-square"
: "aspect-video"
}
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
`}
>
{currentlyPlaying?.url && (
// <Video
// ref={videoRef}
// allowsExternalPlayback
// style={{ width: "100%", height: "100%" }}
// playWhenInactive={true}
// playInBackground={true}
// showNotificationControls={true}
// ignoreSilentSwitch="ignore"
// controls={false}
// pictureInPicture={true}
// poster={
// backdropUrl && currentlyPlaying.item?.Type === "Audio"
// ? backdropUrl
// : undefined
// }
// debug={{
// enable: true,
// thread: true,
// }}
// paused={!isPlaying}
// onProgress={(e) => onProgress(e)}
// subtitleStyle={{
// fontSize: 16,
// }}
// source={{
// uri: currentlyPlaying.url,
// isNetwork: true,
// startPosition,
// headers: getAuthHeaders(api),
// }}
// onBuffer={(e) =>
// e.isBuffering ? console.log("Buffering...") : null
// }
// onFullscreenPlayerDidDismiss={() => {}}
// onFullscreenPlayerDidPresent={() => {}}
// onPlaybackStateChanged={(e) => {
// if (e.isPlaying) {
// setIsPlaying(true);
// } else if (e.isSeeking) {
// return;
// } else {
// setIsPlaying(false);
// }
// }}
// progressUpdateInterval={2000}
// onError={(e) => {
// console.log(e);
// writeToLog(
// "ERROR",
// "Video playback error: " + JSON.stringify(e)
// );
// Alert.alert("Error", "Cannot play this video file.");
// setIsPlaying(false);
// // setCurrentlyPlaying(null);
// }}
// renderLoader={
// currentlyPlaying.item?.Type !== "Audio" && (
// <View className="flex flex-col items-center justify-center h-full">
// <Loader />
// </View>
// )
// }
// />
<VlCPlayerView
style={{
width: "100%",
height: "100%",
{currentlyPlaying.playbackUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
? backdropUrl
: undefined
}
debug={{
enable: true,
thread: true,
}}
paused={!playing}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: encodeURIComponent(currentlyPlaying.url),
uri: currentlyPlaying.playbackUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}}
key={"1"}
autoAspectRatio={true}
resizeMode="cover"
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {
setFullScreen(false);
}}
onFullscreenPlayerDidPresent={() => {
setFullScreen(true);
}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPlaying(true);
} else if (e.isSeeking) {
return;
} else {
setPlaying(false);
}
}}
progressUpdateInterval={1000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setPlaying(false);
setCurrentlyPlaying(null);
}}
renderLoader={
item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
)
}
/>
)}
</TouchableOpacity>
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
if (currentlyPlaying.item?.Type === "Audio")
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
else router.push(`/items/${currentlyPlaying.item?.Id}`);
if (item?.Type === "Audio")
router.push(`/albums/${item?.AlbumId}`);
else router.push(`/items/${item?.Id}`);
}}
>
<Text>{currentlyPlaying.item?.Name}</Text>
<Text>{item?.Name}</Text>
</TouchableOpacity>
{currentlyPlaying.item?.Type === "Episode" && (
{item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
);
router.push(`/(auth)/series/${item.SeriesId}`);
}}
className="text-xs opacity-50"
>
<Text>{currentlyPlaying.item.SeriesName}</Text>
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
)}
{currentlyPlaying.item?.Type === "Movie" && (
{item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
{item?.ProductionYear}
</Text>
</View>
)}
{currentlyPlaying.item?.Type === "Audio" && (
{item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
router.push(`/albums/${item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
<Text className="text-xs opacity-50">{item?.Album}</Text>
</TouchableOpacity>
)}
</View>
@@ -260,12 +334,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (isPlaying) pauseVideo();
else playVideo();
if (playing) setPlaying(false);
else setPlaying(true);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{isPlaying ? (
{playing ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
@@ -273,7 +347,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
setCurrentlyPlaying(null);
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>

View File

@@ -8,6 +8,17 @@ type ItemCardProps = {
item: BaseItemDto;
};
function seasonNameToIndex(seasonName: string | null | undefined) {
if (!seasonName) return -1;
if (seasonName.startsWith("Season")) {
return parseInt(seasonName.replace("Season ", ""));
}
if (seasonName.startsWith("Specials")) {
return 0;
}
return -1;
}
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col h-12">
@@ -17,7 +28,9 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
{item.SeriesName}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
{`S${seasonNameToIndex(
item?.SeasonName,
)}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name}
</Text>
</>

View File

@@ -0,0 +1,36 @@
import { Text } from "@/components/common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { getLocales } from "expo-localization";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export const LanguageSwitcher: React.FC<Props> = ({ ...props }) => {
const { i18n } = useTranslation();
const lngs = ["en", "sv"];
const [settings, updateSettings] = useSettings();
return (
<View className="flex flex-row space-x-2" {...props}>
{lngs.map((l) => (
<TouchableOpacity
key={l}
onPress={() => {
i18n.changeLanguage(l);
updateSettings({ preferedLanguage: l });
}}
>
<Text
className={`uppercase ${
i18n.language === l ? "text-blue-500" : "text-gray-400 underline"
}`}
>
{l}
</Text>
</TouchableOpacity>
))}
</View>
);
};

View File

@@ -1,30 +1,27 @@
import { usePlayback } from "@/providers/PlaybackProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import CastContext, {
PlayServicesState,
useRemoteMediaClient,
} from "react-native-google-cast";
import { Button } from "./Button";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
url?: string | null;
item: BaseItemDto;
onPress: (type?: "cast" | "device") => void;
chromecastReady: boolean;
}
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
...props
}) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
const _onPress = () => {
if (!chromecastReady) {
onPress("device");
return;
}
@@ -36,45 +33,28 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
options,
cancelButtonIndex,
},
async (selectedIndex: number | undefined) => {
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
onPress("cast");
break;
case 1:
setCurrentlyPlayingState({ item, url });
onPress("device");
break;
case cancelButtonIndex:
break;
}
}
},
);
};
return (
<Button
onPress={onPress}
onPress={_onPress}
iconRight={
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
{client && <Feather name="cast" size={22} color="white" />}
{chromecastReady && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}

View File

@@ -46,6 +46,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return;
}
// Movies and all other cases
if (item.Type === "BoxSet") {
router.push(`/collections/${item.Id}`);
return;

View File

@@ -8,12 +8,12 @@ import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { useSettings } from "@/utils/atoms/settings";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "@/utils/atoms/playState";
} from "../CurrentlyPlayingBar";
import { useSettings } from "@/utils/atoms/settings";
interface EpisodeCardProps {
item: BaseItemDto;

View File

@@ -9,13 +9,12 @@ import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useSettings } from "@/utils/atoms/settings";
import {
currentlyPlayingItemAtom,
playingAtom,
fullScreenAtom,
} from "@/utils/atoms/playState";
playingAtom,
} from "../CurrentlyPlayingBar";
import { useSettings } from "@/utils/atoms/settings";
interface MovieCardProps {
item: BaseItemDto;
@@ -82,12 +81,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
loop={false}
alignOffset={0}
avoidCollisions={false}
collisionPadding={0}
>
<ContextMenu.Content>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}

View File

@@ -1,6 +1,8 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
@@ -57,7 +59,7 @@ export const FilterButton = <T,>({
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}

View File

@@ -155,7 +155,7 @@ export const FilterSheet = <T,>({
setOpen(false);
}, 250);
}}
key={`${index}`}
key={index}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
<Text>{renderItemLabel(item)}</Text>

View File

@@ -41,7 +41,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
key={index}
item={item}
className={`flex flex-col
${orientation === "vertical" ? "w-28" : "w-44"}
${orientation === "vertical" ? "w-32" : "w-48"}
`}
>
<View>

View File

@@ -13,7 +13,6 @@ import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import MoviePoster from "../posters/MoviePoster";
import { useCallback } from "react";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
collection: BaseItemDto;
@@ -57,12 +56,11 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
key={index}
item={item}
className={`flex flex-col
${"w-28"}
${"w-32"}
`}
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}

View File

@@ -10,13 +10,11 @@ import Poster from "../posters/Poster";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { router, usePathname } from "expo-router";
import { router } from "expo-router";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
const pathname = usePathname();
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
@@ -25,7 +23,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/search?q=${item.Name}&prev=${pathname}`);
// TODO: Navigate to person
}}
key={item.Id}
className="flex flex-col w-32"

View File

@@ -52,7 +52,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
router.push(`/(auth)/items/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-44"
className="flex flex-col w-32"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />

View File

@@ -1,15 +1,12 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { Linking, Switch, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { Input } from "../common/Input";
import { useState } from "react";
import { Button } from "../Button";
export const SettingToggles: React.FC = () => {
const [settings, updateSettings] = useSettings();
@@ -17,10 +14,6 @@ export const SettingToggles: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const queryClient = useQueryClient();
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
@@ -215,89 +208,6 @@ export const SettingToggles: React.FC = () => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<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">Search engine</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?.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
{settings?.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
<>
<View className="flex flex-row items-center space-x-2">
<View className="grow">
<Input
placeholder="Marlin Server URL..."
defaultValue={settings.marlinServerUrl}
value={marlinUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setMarlinUrl(text)}
/>
</View>
<Button
color="purple"
className="shrink w-16 h-12"
onPress={() => {
updateSettings({ marlinServerUrl: marlinUrl });
}}
>
Save
</Button>
</View>
<Text className="text-neutral-500 mt-2">
{settings?.marlinServerUrl}
</Text>
</>
</View>
)}
</View>
</View>
);
};

View File

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

22
i18n.ts Normal file
View File

@@ -0,0 +1,22 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./translations/en.json";
import sv from "./translations/sv.json";
import { getLocales } from "expo-localization";
i18n.use(initReactI18next).init({
compatibilityJSON: "v3",
resources: {
en: { translation: en },
sv: { translation: sv },
},
lng: getLocales()[0].languageCode || "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
export default i18n;

View File

@@ -41,6 +41,7 @@
"expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-localization": "~15.0.3",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5",
@@ -51,11 +52,13 @@
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"i18next": "^23.13.0",
"jotai": "^2.9.1",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-i18next": "^15.0.1",
"react-native": "0.74.5",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
@@ -72,7 +75,6 @@
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.4.3",
"react-native-vlc-media-player": "^1.0.69",
"react-native-web": "~0.19.10",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",

View File

@@ -1,12 +1,8 @@
import {
currentlyPlayingItemAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isLoaded } from "expo-font";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -25,7 +21,6 @@ interface Server {
export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null);
export const wsAtom = atom<WebSocket | null>(null);
interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
@@ -36,7 +31,7 @@ interface JellyfinContextValue {
}
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined
undefined,
);
const getOrSetDeviceId = async () => {
@@ -54,8 +49,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [isConnected, setIsConnected] = useState<boolean>(false);
useEffect(() => {
(async () => {
@@ -63,11 +56,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.2" },
clientInfo: { name: "Streamyfin", version: "0.6.1" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
}),
);
setDeviceId(id);
})();
}, []);
@@ -75,9 +67,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom);
const discoverServers = async (url: string): Promise<Server[]> => {
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
url
);
const servers =
await jellyfin?.discovery.getRecommendedServerCandidates(url);
return servers?.map((server) => ({ address: server.address })) || [];
};
@@ -153,7 +144,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string
(await AsyncStorage.getItem("user")) as string,
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {

View File

@@ -1,269 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useSettings } from "@/utils/atoms/settings";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
import { getDeviceId } from "@/utils/device";
type CurrentlyPlayingState = {
url: string;
item: BaseItemDto;
};
interface PlaybackContextType {
sessionData: PlaybackInfoResponse | null | undefined;
currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
playVideo: () => void;
pauseVideo: () => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
onProgress: (data: OnProgressData) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const videoRef = useRef<VideoRef | null>(null);
const [settings] = useSettings();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
// WS
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
});
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
queryFn: getDeviceId,
});
const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => {
if (state) {
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault)
presentFullscreenPlayer();
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
};
// Define control methods
const playVideo = useCallback(() => {
videoRef.current?.resume();
setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: true,
});
}, [
api,
currentlyPlaying?.item.Id,
sessionData?.PlaySessionId,
progressTicks,
]);
const pauseVideo = useCallback(() => {
videoRef.current?.pause();
setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: false,
});
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
const stopPlayback = useCallback(async () => {
await reportPlaybackStopped({
api,
itemId: currentlyPlaying?.item?.Id,
sessionId: sessionData?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
setCurrentlyPlayingState(null);
}, [currentlyPlaying, sessionData, progressTicks]);
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
sessionId: sessionData?.PlaySessionId,
IsPaused: !isPlaying,
});
},
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
);
const presentFullscreenPlayer = useCallback(() => {
videoRef.current?.presentFullscreenPlayer();
setIsFullscreen(true);
}, []);
const dismissFullscreenPlayer = useCallback(() => {
videoRef.current?.dismissFullscreenPlayer();
setIsFullscreen(false);
}, []);
useEffect(() => {
if (!deviceId || !api) return;
const url = `wss://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
console.log("WS", url);
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
console.log("KeepAlive message sent");
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
console.log("WebSocket connection closed:", e.reason);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId]);
useEffect(() => {
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
}
};
}, [ws, stopPlayback, playVideo, pauseVideo]);
return (
<PlaybackContext.Provider
value={{
onProgress,
progressTicks,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData,
videoRef,
playVideo,
setCurrentlyPlayingState,
pauseVideo,
stopPlayback,
presentFullscreenPlayer,
dismissFullscreenPlayer,
}}
>
{children}
</PlaybackContext.Provider>
);
};
export const usePlayback = () => {
const context = useContext(PlaybackContext);
if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider");
}
return context;
};

38
translations/en.json Normal file
View File

@@ -0,0 +1,38 @@
{
"login": {
"username_required": "Username is required",
"error_title": "Error",
"url_error_message": "URL needs to start with http or https.",
"login": "Log in",
"login_subtitle": "Log in to any user account",
"username_placeholder": "Username",
"password_placeholder": "Password",
"login_button": "Log in"
},
"server": {
"server_label": "Server: {{serverURL}}",
"change_server": "Change server",
"connect_to_server": "Connect to your Jellyfin server",
"server_url_placeholder": "Server URL",
"server_url_hint": "Server URL requires http or https",
"connect_button": "Connect"
},
"home": {
"home": "Home",
"noInternet": "No Internet",
"noInternetMessage": "No worries, you can still watch\ndownloaded content.",
"goToDownloads": "Go to downloads",
"oops": "Oops!",
"errorMessage": "Something went wrong.\nPlease log out and in again.",
"continueWatching": "Continue Watching",
"nextUp": "Next Up",
"recentlyAddedMovies": "Recently Added in Movies",
"recentlyAddedTVShows": "Recently Added in TV-Shows",
"suggestions": "Suggestions"
},
"tabs": {
"home": "Home",
"search": "Search",
"library": "Library"
}
}

38
translations/sv.json Normal file
View File

@@ -0,0 +1,38 @@
{
"login": {
"username_required": "Användarnamn krävs",
"error_title": "Fel",
"url_error_message": "URL måste börja med http eller https.",
"login_title": "Logga in",
"login_subtitle": "Logga in på ett användarkonto",
"username_placeholder": "Användarnamn",
"password_placeholder": "Lösenord",
"login_button": "Logga in"
},
"server": {
"server_label": "Server: {{serverURL}}",
"change_server": "Byt server",
"connect_to_server": "Anslut till din Jellyfin-server",
"server_url_placeholder": "Server URL",
"server_url_hint": "Server URL kräver http eller https",
"connect_button": "Anslut"
},
"home": {
"home": "Hem",
"noInternet": "Ingen Internet",
"noInternetMessage": "Ingen fara, du kan fortfarande titta\npå nedladdat innehåll.",
"goToDownloads": "Gå till nedladdningar",
"oops": "Hoppsan!",
"errorMessage": "Något gick fel.\nLogga ut och in igen.",
"continueWatching": "Fortsätt titta",
"nextUp": "Nästa upp",
"recentlyAddedMovies": "Nyligen tillagt i Filmer",
"recentlyAddedTVShows": "Nyligen tillagt i TV-Serier",
"suggestions": "Förslag"
},
"tabs": {
"home": "Hem",
"search": "Sök",
"library": "Bibliotek"
}
}

View File

@@ -1,10 +0,0 @@
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

@@ -1,6 +1,7 @@
import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
import { getLocales } from "expo-localization";
type Settings = {
autoRotate?: boolean;
@@ -10,8 +11,7 @@ type Settings = {
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
preferedLanguage?: string;
};
/**
@@ -35,8 +35,7 @@ const loadSettings = async (): Promise<Settings> => {
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
preferedLanguage: getLocales()[0] || "en",
};
};

View File

@@ -1,19 +0,0 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import uuid from "react-native-uuid";
export const getOrSetDeviceId = async () => {
let deviceId = await AsyncStorage.getItem("deviceId");
if (!deviceId) {
deviceId = uuid.v4() as string;
await AsyncStorage.setItem("deviceId", deviceId);
}
return deviceId;
};
export const getDeviceId = async () => {
let deviceId = await AsyncStorage.getItem("deviceId");
return deviceId || null;
};

View File

@@ -53,7 +53,7 @@ export const getStreamUrl = async ({
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
},
);
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
@@ -69,16 +69,7 @@ export const getStreamUrl = async ({
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
const params = new URLSearchParams({
mediaSourceId: itemId,
Static: "true",
deviceId: api.deviceInfo.id,
api_key: api.accessToken,
Tag: item.MediaSources?.[0].ETag || "",
});
return `${api.basePath}/Videos/${itemId}/stream.mp4?${params.toString()}`;
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
@@ -96,9 +87,7 @@ export const getStreamUrl = async ({
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return `${
api.basePath
}/Audio/${itemId}/universal?${searchParams.toString()}`;
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
}

View File

@@ -1,13 +1,12 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
interface ReportPlaybackProgressParams {
api?: Api | null;
sessionId?: string | null;
itemId?: string | null;
positionTicks?: number | null;
IsPaused?: boolean;
api: Api;
sessionId: string;
itemId: string;
positionTicks: number;
}
/**
@@ -21,44 +20,25 @@ export const reportPlaybackProgress = async ({
sessionId,
itemId,
positionTicks,
IsPaused = false,
}: ReportPlaybackProgressParams): Promise<void> => {
if (!api || !sessionId || !itemId || !positionTicks) {
console.error(
"Missing required parameter",
sessionId,
itemId,
positionTicks
);
return;
}
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
try {
await postCapabilities({
api,
itemId,
sessionId,
});
} catch (error) {
console.error("Failed to post capabilities.", error);
throw new Error("Failed to post capabilities.");
}
console.info(
"Reporting playback progress:",
sessionId,
itemId,
positionTicks,
);
try {
await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,
PlaySessionId: sessionId,
IsPaused,
IsPaused: false,
PositionTicks: Math.round(positionTicks),
CanSeek: true,
MediaSourceId: itemId,
EventName: "timeupdate",
},
{ headers: getAuthHeaders(api) }
{ headers: getAuthHeaders(api) },
);
} catch (error) {
console.error(error);

View File

@@ -41,15 +41,12 @@ export const reportPlaybackStopped = async ({
return;
}
console.log("reportPlaybackStopped ~", { sessionId, itemId });
try {
const url = `${api.basePath}/PlayingItems/${itemId}`;
const params = {
playSessionId: sessionId,
positionTicks: Math.round(positionTicks),
MediaSourceId: itemId,
IsPaused: true,
mediaSourceId: itemId,
};
const headers = getAuthHeaders(api);
@@ -61,7 +58,7 @@ export const reportPlaybackStopped = async ({
console.error(
"Failed to report playback progress",
error.message,
error.response?.data
error.response?.data,
);
} else {
console.error("Failed to report playback progress", error);

View File

@@ -1,48 +0,0 @@
import { Api } from "@jellyfin/sdk";
import {
SessionApi,
SessionApiPostCapabilitiesRequest,
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
}
/**
* Marks a media item as not played for a specific user.
*
* @param params - The parameters for marking an item as not played
* @returns A promise that resolves to true if the operation was successful, false otherwise
*/
export const postCapabilities = async ({
api,
itemId,
sessionId,
}: PostCapabilitiesParams): Promise<void> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing required parameters");
}
try {
const r = await api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
playableMediaTypes: ["Audio", "Video", "Audio"],
supportedCommands: ["PlayState", "Play"],
supportsMediaControl: true,
id: sessionId,
},
{
headers: getAuthHeaders(api),
}
);
} catch (error: any | AxiosError) {
console.log("Failed to mark as not played", error);
throw new Error("Failed to mark as not played");
}
};