mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-22 00:34:43 +01:00
Compare commits
3 Commits
v0.8.1
...
feat/vlc-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29e6a3815 | ||
|
|
92b847a447 | ||
|
|
e7fcf806b3 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,4 +29,3 @@ pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
|||||||
credentials.json
|
credentials.json
|
||||||
*.apk
|
*.apk
|
||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -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.
|
||||||
|
|||||||
15
app.json
15
app.json
@@ -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": {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -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"
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
10
utils/atoms/playState.ts
Normal 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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user