Compare commits

..

3 Commits

Author SHA1 Message Date
Fredrik Burmester
a29e6a3815 wip 2024-08-20 20:55:21 +02:00
Fredrik Burmester
92b847a447 wip 2024-08-20 19:59:13 +02:00
Fredrik Burmester
e7fcf806b3 wip 2024-08-20 19:59:10 +02:00
34 changed files with 461 additions and 725 deletions

1
.gitignore vendored
View File

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

View File

@@ -26,33 +26,18 @@ Streamyfin includes some exciting experimental features like media downloading a
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
### Chromecast
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
## Plugins
In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
### Collection rows ### Collection rows
Jellyfin collections can be shown as rows or carousel on the home screen. Jellyfin collections can be shown as rows or carousel on the home screen.
The following tags can be added to an collection to provide this functionality. The following tags can be added to an collection to provide this functionality.
Avaiable tags: Avaiable tags:
- sf_promoted: Wil make the collection an row on home - sf_promoted: Wil make the collection an row on home
- sf_carousel: Wil make the collection an carousel 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. 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. See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
### Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
## Roadmap for V1 ## 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. 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.

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.8.1", "version": "0.6.2",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -30,7 +30,7 @@
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 21, "versionCode": 17,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png" "foregroundImage": "./assets/images/icon.png"
}, },
@@ -96,6 +96,17 @@
{ {
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." "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
}
}
] ]
], ],
"experiments": { "experiments": {

View File

@@ -171,19 +171,30 @@ export default function index() {
}); });
const { data: mediaListCollections } = useQuery({ const { data: mediaListCollections } = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin], queryKey: [
"mediaListCollections-home",
user?.Id,
settings?.mediaListCollectionIds,
],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id) return []; if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user.Id, userId: user.Id,
tags: ["sf_promoted"], tags: ["medialist", "promoted"],
recursive: true, recursive: true,
fields: ["Tags"], fields: ["Tags"],
includeItemTypes: ["BoxSet"], includeItemTypes: ["BoxSet"],
}); });
return response.data.Items || []; const ids =
response.data.Items?.filter(
(c) =>
c.Name !== "cf_carousel" &&
settings?.mediaListCollectionIds?.includes(c.Id!)
) ?? [];
return ids;
}, },
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0, staleTime: 0,
@@ -197,10 +208,7 @@ export default function index() {
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] }); await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] }); await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({ await queryClient.refetchQueries({
queryKey: ["sf_promoted"], queryKey: ["mediaListCollections-home"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
}); });
setLoading(false); setLoading(false);
}, [queryClient, user?.Id]); }, [queryClient, user?.Id]);

View File

@@ -27,9 +27,8 @@ import {
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router"; import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native"; import { NativeScrollEvent, ScrollView, View } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
const isCloseToBottom = ({ const isCloseToBottom = ({
layoutMeasurement, layoutMeasurement,
@@ -57,27 +56,6 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom); const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
// Set the initial orientation
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, [ScreenOrientation]);
const { data: library } = useQuery({ const { data: library } = useQuery({
queryKey: ["library", libraryId], queryKey: ["library", libraryId],
queryFn: async () => { queryFn: async () => {
@@ -333,14 +311,8 @@ const page: React.FC = () => {
<TouchableItemRouter <TouchableItemRouter
key={`${item.Id}-${index}`} key={`${item.Id}-${index}`}
style={{ style={{
width: width: "32%",
orientation === ScreenOrientation.Orientation.PORTRAIT_UP marginBottom: 4,
? "32%"
: "20%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? 4
: 16,
}} }}
item={item} item={item}
className={` className={`
@@ -354,10 +326,7 @@ const page: React.FC = () => {
{flatData.length % 3 !== 0 && ( {flatData.length % 3 !== 0 && (
<View <View
style={{ style={{
width: width: "33%",
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? "32%"
: "20%",
}} }}
></View> ></View>
)} )}

View File

@@ -59,14 +59,14 @@ const page: React.FC = () => {
useEffect(() => { useEffect(() => {
setSortBy([ setSortBy([
{ {
key: "PremiereDate", key: "ProductionYear",
value: "Premiere Date", value: "Production Year",
}, },
]); ]);
setSortOrder([ setSortOrder([
{ {
key: "Ascending", key: "Descending",
value: "Ascending", value: "Descending",
}, },
]); ]);
}, []); }, []);

View File

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

View File

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

View File

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

View File

@@ -44,11 +44,8 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password); await login(credentials.username, credentials.password);
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { const e = error as AxiosError;
setError(error.message); setError(e.message);
} else {
setError("An unexpected error occurred");
}
} finally { } finally {
setLoading(false); setLoading(false);
} }

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import Animated, {
import Video from "react-native-video"; import Video from "react-native-video";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
import { VLCPlayer, VlCPlayerView } from "react-native-vlc-media-player";
export const CurrentlyPlayingBar: React.FC = () => { export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments(); const segments = useSegments();
@@ -137,68 +138,80 @@ export const CurrentlyPlayingBar: React.FC = () => {
`} `}
> >
{currentlyPlaying?.url && ( {currentlyPlaying?.url && (
<Video // <Video
ref={videoRef} // ref={videoRef}
allowsExternalPlayback // allowsExternalPlayback
style={{ width: "100%", height: "100%" }} // style={{ width: "100%", height: "100%" }}
playWhenInactive={true} // playWhenInactive={true}
playInBackground={true} // playInBackground={true}
showNotificationControls={true} // showNotificationControls={true}
ignoreSilentSwitch="ignore" // ignoreSilentSwitch="ignore"
controls={false} // controls={false}
pictureInPicture={true} // pictureInPicture={true}
poster={ // poster={
backdropUrl && currentlyPlaying.item?.Type === "Audio" // backdropUrl && currentlyPlaying.item?.Type === "Audio"
? backdropUrl // ? backdropUrl
: undefined // : undefined
} // }
debug={{ // debug={{
enable: true, // enable: true,
thread: true, // thread: true,
}} // }}
paused={!isPlaying} // paused={!isPlaying}
onProgress={(e) => onProgress(e)} // onProgress={(e) => onProgress(e)}
subtitleStyle={{ // subtitleStyle={{
fontSize: 16, // 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%",
}} }}
source={{ source={{
uri: currentlyPlaying.url, uri: encodeURIComponent(currentlyPlaying.url),
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}} }}
onBuffer={(e) => key={"1"}
e.isBuffering ? console.log("Buffering...") : null autoAspectRatio={true}
} resizeMode="cover"
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>
)
}
/> />
)} )}
</TouchableOpacity> </TouchableOpacity>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,34 +34,6 @@ interface Props<T> extends ViewProps {
const LIMIT = 100; const LIMIT = 100;
/**
* FilterSheet Component
*
* This component creates a bottom sheet modal for filtering and selecting items from a list.
*
* @template T - The type of items in the list
*
* @param {Object} props - The component props
* @param {boolean} props.open - Whether the bottom sheet is open
* @param {function} props.setOpen - Function to set the open state
* @param {T[] | null} [props.data] - The full list of items to filter from
* @param {T[]} props.values - The currently selected items
* @param {function} props.set - Function to update the selected items
* @param {string} props.title - The title of the bottom sheet
* @param {function} props.searchFilter - Function to filter items based on search query
* @param {function} props.renderItemLabel - Function to render the label for each item
* @param {boolean} [props.showSearch=true] - Whether to show the search input
*
* @returns {React.ReactElement} The FilterSheet component
*
* Features:
* - Displays a list of items in a bottom sheet
* - Allows searching and filtering of items
* - Supports single selection of items
* - Loads items in batches for performance optimization
* - Customizable item rendering
*/
export const FilterSheet = <T,>({ export const FilterSheet = <T,>({
values, values,
data: _data, data: _data,
@@ -93,8 +65,6 @@ export const FilterSheet = <T,>({
return results.slice(0, 100); return results.slice(0, 100);
}, [search, _data, searchFilter]); }, [search, _data, searchFilter]);
// Loads data in batches of LIMIT size, starting from offset,
// to implement efficient "load more" functionality
useEffect(() => { useEffect(() => {
if (!_data || _data.length === 0) return; if (!_data || _data.length === 0) return;
const tmp = new Set(data); const tmp = new Set(data);
@@ -176,12 +146,14 @@ export const FilterSheet = <T,>({
<> <>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
if (!values.includes(item)) { set(
set([item]); values.includes(item)
setTimeout(() => { ? values.filter((i) => i !== item)
setOpen(false); : [item]
}, 250); );
} setTimeout(() => {
setOpen(false);
}, 250);
}} }}
key={`${index}`} key={`${index}`}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between" className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"

View File

@@ -31,25 +31,6 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { data: sf_carousel, isFetching: l1 } = useQuery({
queryKey: ["sf_carousel", user?.Id, settings?.mediaListCollectionIds],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_carousel"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items?.[0].Id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const onPressPagination = (index: number) => { const onPressPagination = (index: number) => {
ref.current?.scrollTo({ ref.current?.scrollTo({
/** /**
@@ -61,20 +42,40 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
}); });
}; };
const { data: popularItems, isFetching: l2 } = useQuery<BaseItemDto[]>({ const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
queryKey: ["popular", user?.Id], queryKey: ["mediaListCollection", user?.Id],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id || !sf_carousel) return []; if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user.Id, userId: user.Id,
parentId: sf_carousel, tags: ["medialist", "promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
return id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !mediaListCollection) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: mediaListCollection,
limit: 10, limit: 10,
}); });
return response.data.Items || []; return response.data.Items || [];
}, },
enabled: !!api && !!user?.Id && !!sf_carousel, enabled: !!api && !!user?.Id && !!mediaListCollection,
staleTime: 0, staleTime: 0,
}); });
@@ -93,7 +94,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
<View className="flex flex-col items-center" {...props}> <View className="flex flex-col items-center" {...props}>
<Carousel <Carousel
autoPlay={true} autoPlay={true}
autoPlayInterval={3000} autoPlayInterval={2000}
loop={true} loop={true}
ref={ref} ref={ref}
width={width} width={width}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { DownloadOptions, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -25,19 +25,22 @@ export const SettingToggles: React.FC = () => {
data: mediaListCollections, data: mediaListCollections,
isLoading: isLoadingMediaListCollections, isLoading: isLoadingMediaListCollections,
} = useQuery({ } = useQuery({
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin], queryKey: ["mediaListCollections", user?.Id],
queryFn: async () => { queryFn: async () => {
if (!api || !user?.Id) return []; if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
userId: user.Id, userId: user.Id,
tags: ["sf_promoted"], tags: ["medialist", "promoted"],
recursive: true, recursive: true,
fields: ["Tags"], fields: ["Tags"],
includeItemTypes: ["BoxSet"], includeItemTypes: ["BoxSet"],
}); });
return response.data.Items ?? []; const ids =
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
return ids;
}, },
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0, staleTime: 0,
@@ -58,46 +61,6 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ autoRotate: value })} onValueChange={(value) => updateSettings({ autoRotate: value })}
/> />
</View> </View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Download quality</Text>
<Text className="text-xs opacity-50">
Choose the search engine you want to use.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.downloadQuality?.label}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Quality</DropdownMenu.Label>
{DownloadOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
onSelect={() => {
updateSettings({ downloadQuality: option });
}}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink"> <View className="shrink">
<Text className="font-semibold">Start videos in fullscreen</Text> <Text className="font-semibold">Start videos in fullscreen</Text>
@@ -113,23 +76,6 @@ export const SettingToggles: React.FC = () => {
} }
/> />
</View> </View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This requries
VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings?.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col"> <View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4"> <View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col"> <View className="flex flex-col">
@@ -213,7 +159,6 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ forceDirectPlay: value })} onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
/> />
</View> </View>
<View <View
className={` className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4 flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4

View File

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

View File

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

View File

@@ -38,7 +38,7 @@
"expo-device": "~6.0.2", "expo-device": "~6.0.2",
"expo-font": "~12.0.9", "expo-font": "~12.0.9",
"expo-haptics": "~13.0.1", "expo-haptics": "~13.0.1",
"expo-image": "~1.12.14", "expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2", "expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7", "expo-navigation-bar": "~3.0.7",
@@ -72,6 +72,7 @@
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2", "react-native-uuid": "^2.0.2",
"react-native-video": "^6.4.3", "react-native-video": "^6.4.3",
"react-native-vlc-media-player": "^1.0.69",
"react-native-web": "~0.19.10", "react-native-web": "~0.19.10",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.3", "use-debounce": "^10.0.3",
@@ -85,7 +86,7 @@
"@types/react": "~18.2.45", "@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7", "@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1", "jest": "^29.2.1",
"jest-expo": "~51.0.4", "jest-expo": "~51.0.3",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"typescript": "~5.3.3" "typescript": "~5.3.3"
}, },

View File

@@ -7,7 +7,6 @@ import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models"; import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useSegments } from "expo-router"; import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import React, { import React, {
@@ -64,7 +63,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.8.1" }, clientInfo: { name: "Streamyfin", version: "0.6.2" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}) })
); );
@@ -116,40 +115,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => { }) => {
if (!api || !jellyfin) throw new Error("API not initialized"); if (!api || !jellyfin) throw new Error("API not initialized");
try { const auth = await api.authenticateUserByName(username, password);
const auth = await api.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) { if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User); setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User)); await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken)); setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken); await AsyncStorage.setItem("token", auth.data?.AccessToken);
} } else {
} catch (error) { throw new Error("Invalid username or password");
if (axios.isAxiosError(error)) {
console.log("Axios error", error.response?.status);
switch (error.response?.status) {
case 401:
throw new Error("Invalid username or password");
case 403:
throw new Error("User does not have permission to log in");
case 408:
throw new Error(
"Server is taking too long to respond, try again later"
);
case 429:
throw new Error(
"Server received too many requests, try again later"
);
case 500:
throw new Error("There is a server error");
default:
throw new Error(
"An unexpected error occurred. Did you enter the server URL correctly?"
);
}
}
throw error;
} }
}, },
onError: (error) => { onError: (error) => {

View File

@@ -21,8 +21,6 @@ import { useAtom } from "jotai";
import { OnProgressData, type VideoRef } from "react-native-video"; import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider"; import { apiAtom, userAtom } from "./JellyfinProvider";
import { getDeviceId } from "@/utils/device"; import { getDeviceId } from "@/utils/device";
import * as Linking from "expo-linking";
import { Platform } from "react-native";
type CurrentlyPlayingState = { type CurrentlyPlayingState = {
url: string; url: string;
@@ -89,29 +87,19 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
queryFn: getDeviceId, queryFn: getDeviceId,
}); });
const setCurrentlyPlayingState = useCallback( const setCurrentlyPlayingState = (state: CurrentlyPlayingState | null) => {
(state: CurrentlyPlayingState | null) => { if (state) {
const vlcLink = "vlc://" + state?.url; setCurrentlyPlaying(state);
console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios"); setIsPlaying(true);
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
if (state) { if (settings?.openFullScreenVideoPlayerByDefault)
setCurrentlyPlaying(state); presentFullscreenPlayer();
setIsPlaying(true); } else {
setCurrentlyPlaying(null);
if (settings?.openFullScreenVideoPlayerByDefault) setIsFullscreen(false);
presentFullscreenPlayer(); setIsPlaying(false);
} else { }
setCurrentlyPlaying(null); };
setIsFullscreen(false);
setIsPlaying(false);
}
},
[settings]
);
// Define control methods // Define control methods
const playVideo = useCallback(() => { const playVideo = useCallback(() => {
@@ -179,7 +167,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!deviceId || !api?.accessToken) return; if (!deviceId || !api) return;
const url = `wss://${api?.basePath const url = `wss://${api?.basePath
.replace("https://", "") .replace("https://", "")
@@ -224,7 +212,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
} }
newWebSocket.close(); newWebSocket.close();
}; };
}, [api, deviceId, user]); }, [api, deviceId]);
useEffect(() => { useEffect(() => {
if (!ws) return; if (!ws) return;

View File

@@ -13,9 +13,7 @@ export const sortOptions: {
{ key: "SortName", value: "Name" }, { key: "SortName", value: "Name" },
{ key: "CommunityRating", value: "Community Rating" }, { key: "CommunityRating", value: "Community Rating" },
{ key: "CriticRating", value: "Critics Rating" }, { key: "CriticRating", value: "Critics Rating" },
{ key: "DateCreated", value: "Date Added" }, { key: "DateLastContentAdded", value: "Content Added" },
// Only works for shows (last episode added) keeping for future ref.
// { key: "DateLastContentAdded", value: "Content Added" },
{ key: "DatePlayed", value: "Date Played" }, { key: "DatePlayed", value: "Date Played" },
{ key: "PlayCount", value: "Play Count" }, { key: "PlayCount", value: "Play Count" },
{ key: "ProductionYear", value: "Production Year" }, { key: "ProductionYear", value: "Production Year" },
@@ -25,8 +23,7 @@ export const sortOptions: {
{ key: "StartDate", value: "Start Date" }, { key: "StartDate", value: "Start Date" },
{ key: "IsUnplayed", value: "Is Unplayed" }, { key: "IsUnplayed", value: "Is Unplayed" },
{ key: "IsPlayed", value: "Is Played" }, { key: "IsPlayed", value: "Is Played" },
// Broken in JF { key: "VideoBitRate", value: "Video Bit Rate" },
// { key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "AirTime", value: "Air Time" }, { key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" }, { key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" }, { key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },

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

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

View File

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

View File

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