Compare commits

...

31 Commits

Author SHA1 Message Date
Fredrik Burmester
bbf926e752 fix: limit next up items 2024-08-22 13:11:59 +02:00
Fredrik Burmester
9b2a0487d2 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-22 11:28:09 +02:00
Fredrik Burmester
a73488614c fix: limit to 20 items for next up on home screen 2024-08-22 11:28:03 +02:00
Fredrik Burmester
03fdf31b4b Update README.md 2024-08-22 09:21:58 +02:00
Fredrik Burmester
2e563bcbe0 chore 2024-08-21 23:21:59 +02:00
Fredrik Burmester
b1062628d9 fix: can't play local media bug
appeared after playback rewrite
2024-08-21 23:21:53 +02:00
Fredrik Burmester
e216c8392f fix: jellysearch + chromecast 2024-08-21 23:20:40 +02:00
Fredrik Burmester
1a47ade4dc chore: version 2024-08-21 21:05:53 +02:00
Fredrik Burmester
a54da1c3dc fix: open google search for actor if Marlin turned off 2024-08-21 20:37:50 +02:00
Fredrik Burmester
874364fcde feat: new list view for seasons with persistent season state 2024-08-21 20:34:52 +02:00
Fredrik Burmester
9059f33538 fix: smaller height for dropdown menus 2024-08-21 20:34:24 +02:00
Fredrik Burmester
27785e7d18 feat: new download quality options 2024-08-21 20:34:07 +02:00
Fredrik Burmester
9a621cab4e fix: add option for height 2024-08-21 20:21:21 +02:00
Fredrik Burmester
a37ac74e9f chore 2024-08-21 20:21:06 +02:00
Fredrik Burmester
4d4bb0f6a4 chore 2024-08-21 20:21:00 +02:00
Fredrik Burmester
6bd60b2ec6 fix: #78 2024-08-21 20:20:52 +02:00
Fredrik Burmester
696a2a4780 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-21 18:43:56 +02:00
Fredrik Burmester
13ac9b0443 chore 2024-08-21 18:43:54 +02:00
Fredrik Burmester
83407674be fix: slow down 2024-08-21 18:43:38 +02:00
Fredrik Burmester
53bb1751c2 fix: websocket should not try to connect without token 2024-08-21 18:43:27 +02:00
Fredrik Burmester
fa31ff8f2b Merge pull request #92 from lostb1t/master
Add DateCreated sort option an disable bit rate sort
2024-08-21 18:35:31 +02:00
lostb1t
f863c95f70 Disable bitrate filter 2024-08-21 18:00:06 +02:00
lostb1t
f227565dbf Use DateCreate for sorting last added 2024-08-21 17:58:42 +02:00
Fredrik Burmester
7abc629a10 fix: #87 2024-08-21 08:01:46 +02:00
Fredrik Burmester
70a250df5b fix: #86 and add docsting 2024-08-21 07:48:45 +02:00
Fredrik Burmester
dbdf56b990 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-21 07:33:33 +02:00
Fredrik Burmester
8b2204896a chore 2024-08-21 07:33:22 +02:00
Fredrik Burmester
89729a95cd Merge pull request #85 from yihaolee85/master
Update [collectionId].tsx to fix sort order
2024-08-21 07:31:38 +02:00
yihaolee85
57dd3b8446 Update [collectionId].tsx to fix sort order
Update [collectionId].tsx to fix sort order
2024-08-21 10:02:15 +08:00
Fredrik Burmester
8d2a0378ca fix: correct sf collection use 2024-08-20 22:39:36 +02:00
Fredrik Burmester
cbe01a0012 feat: use external player (vlc) 2024-08-20 21:51:16 +02:00
33 changed files with 677 additions and 372 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
bun.lockb

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,34 @@ interface Props<T> extends ViewProps {
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,>({
values,
data: _data,
@@ -65,6 +93,8 @@ export const FilterSheet = <T,>({
return results.slice(0, 100);
}, [search, _data, searchFilter]);
// Loads data in batches of LIMIT size, starting from offset,
// to implement efficient "load more" functionality
useEffect(() => {
if (!_data || _data.length === 0) return;
const tmp = new Set(data);
@@ -146,14 +176,12 @@ export const FilterSheet = <T,>({
<>
<TouchableOpacity
onPress={() => {
set(
values.includes(item)
? values.filter((i) => i !== item)
: [item]
);
setTimeout(() => {
setOpen(false);
}, 250);
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}}
key={`${index}`}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@
"expo-device": "~6.0.2",
"expo-font": "~12.0.9",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.13",
"expo-image": "~1.12.14",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
@@ -85,7 +85,7 @@
"@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.3",
"jest-expo": "~51.0.4",
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},

View File

@@ -7,6 +7,7 @@ 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 axios from "axios";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -63,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.2" },
clientInfo: { name: "Streamyfin", version: "0.8.1" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -115,15 +116,40 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
const auth = await api.authenticateUserByName(username, password);
try {
const auth = await api.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken);
} else {
throw new Error("Invalid username or password");
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken);
}
} catch (error) {
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) => {

View File

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

View File

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

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

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