mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
19 Commits
fix/save-c
...
v0.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e563bcbe0 | ||
|
|
b1062628d9 | ||
|
|
e216c8392f | ||
|
|
1a47ade4dc | ||
|
|
a54da1c3dc | ||
|
|
874364fcde | ||
|
|
9059f33538 | ||
|
|
27785e7d18 | ||
|
|
9a621cab4e | ||
|
|
a37ac74e9f | ||
|
|
4d4bb0f6a4 | ||
|
|
6bd60b2ec6 | ||
|
|
696a2a4780 | ||
|
|
13ac9b0443 | ||
|
|
83407674be | ||
|
|
53bb1751c2 | ||
|
|
fa31ff8f2b | ||
|
|
f863c95f70 | ||
|
|
f227565dbf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
*.apk
|
||||
*.ipa
|
||||
.continuerc.json
|
||||
|
||||
17
README.md
17
README.md
@@ -26,18 +26,33 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
### Chromecast
|
||||
|
||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
||||
|
||||
## Plugins
|
||||
|
||||
In Streamyfin we have build in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
||||
|
||||
### Collection rows
|
||||
|
||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
||||
The following tags can be added to an collection to provide this functionality.
|
||||
The following tags can be added to an collection to provide this functionality.
|
||||
|
||||
Avaiable tags:
|
||||
|
||||
- sf_promoted: Wil make the collection an row on home
|
||||
- sf_carousel: Wil make the collection an carousel on home.
|
||||
|
||||
A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
|
||||
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
||||
|
||||
### Jellysearch
|
||||
|
||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
||||
|
||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||
|
||||
## Roadmap for V1
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
|
||||
4
app.json
4
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 19,
|
||||
"versionCode": 21,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
|
||||
@@ -27,8 +27,9 @@ import {
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NativeScrollEvent, ScrollView, View } from "react-native";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
|
||||
const isCloseToBottom = ({
|
||||
layoutMeasurement,
|
||||
@@ -56,6 +57,27 @@ const page: React.FC = () => {
|
||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
||||
|
||||
const [orientation, setOrientation] = useState(
|
||||
ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = ScreenOrientation.addOrientationChangeListener(
|
||||
(event) => {
|
||||
setOrientation(event.orientationInfo.orientation);
|
||||
}
|
||||
);
|
||||
|
||||
// Set the initial orientation
|
||||
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
|
||||
setOrientation(initialOrientation);
|
||||
});
|
||||
|
||||
return () => {
|
||||
ScreenOrientation.removeOrientationChangeListener(subscription);
|
||||
};
|
||||
}, [ScreenOrientation]);
|
||||
|
||||
const { data: library } = useQuery({
|
||||
queryKey: ["library", libraryId],
|
||||
queryFn: async () => {
|
||||
@@ -311,8 +333,14 @@ const page: React.FC = () => {
|
||||
<TouchableItemRouter
|
||||
key={`${item.Id}-${index}`}
|
||||
style={{
|
||||
width: "32%",
|
||||
marginBottom: 4,
|
||||
width:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
? "32%"
|
||||
: "20%",
|
||||
marginBottom:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
? 4
|
||||
: 16,
|
||||
}}
|
||||
item={item}
|
||||
className={`
|
||||
@@ -326,7 +354,10 @@ const page: React.FC = () => {
|
||||
{flatData.length % 3 !== 0 && (
|
||||
<View
|
||||
style={{
|
||||
width: "33%",
|
||||
width:
|
||||
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
|
||||
? "32%"
|
||||
: "20%",
|
||||
}}
|
||||
></View>
|
||||
)}
|
||||
|
||||
@@ -15,12 +15,6 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
fullScreenAtom,
|
||||
playingAtom,
|
||||
showCurrentlyPlayingBarAtom,
|
||||
} from "@/utils/atoms/playState";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
@@ -35,13 +29,9 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||
|
||||
const page: React.FC = () => {
|
||||
@@ -55,13 +45,6 @@ const page: React.FC = () => {
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setShowCurrentlyPlayingBar] = useAtom(showCurrentlyPlayingBarAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
@@ -141,47 +124,6 @@ const page: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCurrentlyPlying({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
setShowCurrentlyPlayingBar(true);
|
||||
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setTimeout(() => {
|
||||
setFullscreen(true);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackUrl, item, settings]
|
||||
);
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
() =>
|
||||
getBackdropUrl({
|
||||
@@ -252,7 +194,7 @@ const page: React.FC = () => {
|
||||
|
||||
<View className="flex flex-row justify-between items-center mb-2">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
<DownloadItem item={item} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,7 @@ const page: React.FC = () => {
|
||||
quality: 90,
|
||||
width: 1000,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const logoUrl = useMemo(
|
||||
@@ -52,7 +52,7 @@ const page: React.FC = () => {
|
||||
api,
|
||||
item,
|
||||
}),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
if (!item || !backdropUrl) return null;
|
||||
@@ -87,7 +87,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-24">
|
||||
<View className="flex flex-col pt-4">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
|
||||
@@ -2,10 +2,6 @@ import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
@@ -15,6 +11,7 @@ import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
import { SimilarItems } from "@/components/SimilarItems";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
@@ -41,7 +38,7 @@ const page: React.FC = () => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
@@ -140,7 +137,6 @@ const page: React.FC = () => {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const client = useRemoteMediaClient();
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
@@ -167,11 +163,10 @@ const page: React.FC = () => {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl,
|
||||
url: playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
}
|
||||
},
|
||||
[playbackUrl, item]
|
||||
@@ -224,7 +219,7 @@ const page: React.FC = () => {
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
<DownloadItem item={item} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
@@ -249,12 +244,7 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<PlayButton item={item} className="grow" />
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -22,12 +22,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
const audioStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected],
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,7 +42,7 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
|
||||
@@ -9,10 +9,12 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
type ContinueWatchingPosterProps = {
|
||||
item: BaseItemDto;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
item,
|
||||
width = 176,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
@@ -33,11 +35,21 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
|
||||
if (!url)
|
||||
return (
|
||||
<View className="w-44 aspect-video border border-neutral-800"></View>
|
||||
<View
|
||||
className="aspect-video border border-neutral-800"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
></View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="w-44 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
|
||||
<View
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
|
||||
>
|
||||
<Image
|
||||
key={item.Id}
|
||||
id={item.Id}
|
||||
|
||||
@@ -4,37 +4,132 @@ import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
} from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
import ProgressCircle from "./ProgressCircle";
|
||||
import { DownloadQuality, useSettings } from "@/utils/atoms/settings";
|
||||
import { useCallback } from "react";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
|
||||
type DownloadProps = {
|
||||
interface DownloadProps extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackUrl,
|
||||
}) => {
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [process] = useAtom(runningProcesses);
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
|
||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||
});
|
||||
const { startRemuxing } = useRemuxHlsToMp4(item);
|
||||
|
||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
||||
const initiateDownload = useCallback(
|
||||
async (qualitySetting: DownloadQuality) => {
|
||||
if (!api || !user?.Id || !item.Id) {
|
||||
throw new Error(
|
||||
"DownloadItem ~ initiateDownload: No api or user or item"
|
||||
);
|
||||
}
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
|
||||
if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
}
|
||||
|
||||
let maxStreamingBitrate: number | undefined = undefined;
|
||||
|
||||
if (qualitySetting === "high") {
|
||||
maxStreamingBitrate = 8000000;
|
||||
} else if (qualitySetting === "low") {
|
||||
maxStreamingBitrate = 2000000;
|
||||
}
|
||||
|
||||
const response = await api.axiosInstance.post(
|
||||
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
|
||||
{
|
||||
DeviceProfile: deviceProfile,
|
||||
UserId: user.Id,
|
||||
MaxStreamingBitrate: maxStreamingBitrate,
|
||||
StartTimeTicks: 0,
|
||||
EnableTranscoding: maxStreamingBitrate ? true : undefined,
|
||||
AutoOpenLiveStream: true,
|
||||
MediaSourceId: item.Id,
|
||||
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let url: string | undefined = undefined;
|
||||
|
||||
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
|
||||
|
||||
if (!mediaSource) {
|
||||
throw new Error("No media source");
|
||||
}
|
||||
|
||||
if (mediaSource.SupportsDirectPlay) {
|
||||
if (item.MediaType === "Video") {
|
||||
console.log("Using direct stream for video!");
|
||||
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`;
|
||||
} else if (item.MediaType === "Audio") {
|
||||
console.log("Using direct stream for audio!");
|
||||
const searchParams = new URLSearchParams({
|
||||
UserId: user.Id,
|
||||
DeviceId: api.deviceInfo.id,
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container:
|
||||
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "hls",
|
||||
AudioCodec: "aac",
|
||||
api_key: api.accessToken,
|
||||
StartTimeTicks: "0",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
url = `${api.basePath}/Audio/${
|
||||
item.Id
|
||||
}/universal?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaSource.TranscodingUrl) {
|
||||
console.log("Using transcoded stream!");
|
||||
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
|
||||
} else {
|
||||
throw new Error("No transcoding url");
|
||||
}
|
||||
|
||||
return await startRemuxing(url);
|
||||
},
|
||||
[api, item, startRemuxing, user?.Id]
|
||||
);
|
||||
|
||||
const { data: downloaded, isFetching } = useQuery({
|
||||
queryKey: ["downloaded", item.Id],
|
||||
queryFn: async () => {
|
||||
if (!item.Id) return false;
|
||||
@@ -48,7 +143,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
enabled: !!item.Id,
|
||||
});
|
||||
|
||||
if (isLoading || isLoadingDownloaded) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Loader />
|
||||
@@ -56,20 +151,13 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (process && process?.item.Id === item.Id) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
{process.progress === 0 ? (
|
||||
@@ -96,6 +184,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="hourglass" size={24} color="white" />
|
||||
@@ -110,6 +199,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
@@ -123,11 +213,16 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
queueActions.enqueue(queue, setQueue, {
|
||||
id: item.Id!,
|
||||
execute: async () => {
|
||||
await startRemuxing();
|
||||
// await startRemuxing(playbackUrl);
|
||||
if (!settings?.downloadQuality?.value) {
|
||||
throw new Error("No download quality selected");
|
||||
}
|
||||
await initiateDownload(settings?.downloadQuality?.value);
|
||||
},
|
||||
item,
|
||||
});
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props extends React.ComponentProps<typeof Button> {
|
||||
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const { currentlyPlaying, setCurrentlyPlayingState } = usePlayback();
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const onPress = async () => {
|
||||
if (!url || !item) return;
|
||||
|
||||
@@ -22,14 +22,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
(x) => x.Type === "Subtitle"
|
||||
) ?? [],
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected],
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,7 +50,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{selectedSubtitleSteam
|
||||
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
|
||||
|
||||
@@ -9,11 +9,7 @@ import { useAtom } from "jotai";
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
fullScreenAtom,
|
||||
playingAtom,
|
||||
} from "@/utils/atoms/playState";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
|
||||
interface EpisodeCardProps {
|
||||
item: BaseItemDto;
|
||||
@@ -26,23 +22,15 @@ interface EpisodeCardProps {
|
||||
*/
|
||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
/**
|
||||
* Handles opening the file for playback.
|
||||
*/
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const handleOpenFile = useCallback(async () => {
|
||||
setCurrentlyPlaying({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true)
|
||||
setFullscreen(true);
|
||||
}, [item, setCurrentlyPlaying, settings]);
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
|
||||
@@ -11,11 +11,7 @@ import { useFiles } from "@/hooks/useFiles";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
fullScreenAtom,
|
||||
} from "@/utils/atoms/playState";
|
||||
import { usePlayback } from "@/providers/PlaybackProvider";
|
||||
|
||||
interface MovieCardProps {
|
||||
item: BaseItemDto;
|
||||
@@ -28,25 +24,16 @@ interface MovieCardProps {
|
||||
*/
|
||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
const [settings] = useSettings();
|
||||
|
||||
/**
|
||||
* Handles opening the file for playback.
|
||||
*/
|
||||
const { setCurrentlyPlayingState } = usePlayback();
|
||||
|
||||
const handleOpenFile = useCallback(() => {
|
||||
console.log("Open movie file", item.Name);
|
||||
setCurrentlyPlaying({
|
||||
setCurrentlyPlayingState({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setFullscreen(true);
|
||||
}
|
||||
}, [item, setCurrentlyPlaying, setPlaying, settings]);
|
||||
}, [item, setCurrentlyPlayingState]);
|
||||
|
||||
/**
|
||||
* Handles deleting the file with haptic feedback.
|
||||
|
||||
@@ -93,7 +93,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
|
||||
<View className="flex flex-col items-center" {...props}>
|
||||
<Carousel
|
||||
autoPlay={true}
|
||||
autoPlayInterval={2000}
|
||||
autoPlayInterval={3000}
|
||||
loop={true}
|
||||
ref={ref}
|
||||
width={width}
|
||||
|
||||
@@ -11,10 +11,13 @@ import { useAtom } from "jotai";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@@ -25,7 +28,10 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
|
||||
renderItem={(item, index) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/search?q=${item.Name}&prev=${pathname}`);
|
||||
if (settings?.searchEngine === "Marlin")
|
||||
router.push(`/search?q=${item.Name}&prev=${pathname}`);
|
||||
else
|
||||
Linking.openURL(`https://www.google.com/search?q=${item.Name}`);
|
||||
}}
|
||||
key={item.Id}
|
||||
className="flex flex-col w-32"
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { DownloadItem } from "../DownloadItem";
|
||||
import { Loader } from "../Loader";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<number>(1);
|
||||
type SeasonIndexState = {
|
||||
[seriesId: string]: number;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.Id ?? ""];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -40,7 +47,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
@@ -48,13 +55,24 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
|
||||
const firstSeason = seasons[0];
|
||||
if (firstSeason.IndexNumber !== undefined) {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: firstSeason.IndexNumber,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex],
|
||||
[seasons, seasonIndex]
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
const { data: episodes, isFetching } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item.Id) return [];
|
||||
@@ -70,7 +88,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
@@ -78,8 +96,20 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
// Used for height calculation
|
||||
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
|
||||
useEffect(() => {
|
||||
if (episodes && episodes.length > 0) {
|
||||
setNrOfEpisodes(episodes.length);
|
||||
}
|
||||
}, [episodes]);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<View
|
||||
style={{
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
@@ -102,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSeasonIndex(season.IndexNumber);
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.Id ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
@@ -110,7 +143,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{episodes && (
|
||||
{/* Old View. Might have a setting later to manually select view. */}
|
||||
{/* {episodes && (
|
||||
<View className="mt-4">
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
data={episodes}
|
||||
@@ -128,7 +162,56 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
)} */}
|
||||
<View className="px-4 flex flex-col my-4">
|
||||
{isFetching ? (
|
||||
<View
|
||||
style={{
|
||||
minHeight: 144 * nrOfEpisodes,
|
||||
}}
|
||||
className="flex flex-col items-center justify-center"
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
episodes?.map((e: BaseItemDto) => (
|
||||
<TouchableOpacity
|
||||
key={e.Id}
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${e.Id}`);
|
||||
}}
|
||||
className="flex flex-col mb-4"
|
||||
>
|
||||
<View className="flex flex-row items-center mb-2">
|
||||
<View className="w-32 aspect-video overflow-hidden mr-2">
|
||||
<ContinueWatchingPoster item={e} width={128} />
|
||||
</View>
|
||||
<View className="shrink">
|
||||
<Text numberOfLines={2} className="">
|
||||
{e.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className="text-xs text-neutral-500">
|
||||
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-500">
|
||||
{runtimeTicksToSeconds(e.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="self-start ml-auto">
|
||||
<DownloadItem item={e} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
numberOfLines={3}
|
||||
className="text-xs text-neutral-500 shrink"
|
||||
>
|
||||
{e.Overview}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
@@ -58,6 +58,46 @@ export const SettingToggles: React.FC = () => {
|
||||
onValueChange={(value) => updateSettings({ autoRotate: value })}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
`}
|
||||
>
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Download quality</Text>
|
||||
<Text className="text-xs opacity-50">
|
||||
Choose the search engine you want to use.
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>{settings?.downloadQuality?.label}</Text>
|
||||
</TouchableOpacity>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Quality</DropdownMenu.Label>
|
||||
{DownloadOptions.map((option) => (
|
||||
<DropdownMenu.Item
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
updateSettings({ downloadQuality: option });
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="shrink">
|
||||
<Text className="font-semibold">Start videos in fullscreen</Text>
|
||||
@@ -73,6 +113,23 @@ export const SettingToggles: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This requries
|
||||
VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col">
|
||||
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col">
|
||||
@@ -157,22 +214,6 @@ export const SettingToggles: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
|
||||
<View className="flex flex-col shrink">
|
||||
<Text className="font-semibold">Use external player (VLC)</Text>
|
||||
<Text className="text-xs opacity-50 shrink">
|
||||
Open all videos in VLC instead of the default player. This requries
|
||||
VLC to be installed on the phone.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings?.openInVLC}
|
||||
onValueChange={(value) => {
|
||||
updateSettings({ openInVLC: value, forceDirectPlay: value });
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`
|
||||
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.7.0",
|
||||
"channel": "0.8.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.7.0",
|
||||
"channel": "0.8.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
@@ -14,8 +15,9 @@ import { writeToLog } from "@/utils/log";
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
|
||||
const [_, setProgress] = useAtom(runningProcesses);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||
@@ -23,87 +25,94 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
}
|
||||
|
||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
const startRemuxing = useCallback(async () => {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
||||
);
|
||||
const startRemuxing = useCallback(
|
||||
async (url: string) => {
|
||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev,
|
||||
);
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
||||
);
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
|
||||
);
|
||||
setProgress(null);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
}, [output, item, command, setProgress]);
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev
|
||||
);
|
||||
});
|
||||
|
||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
FFmpegKit.executeAsync(command, async (session) => {
|
||||
try {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
|
||||
);
|
||||
resolve();
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
|
||||
);
|
||||
setProgress(null);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
}
|
||||
},
|
||||
[output, item, setProgress]
|
||||
);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProgress(null);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
|
||||
);
|
||||
}, [item.Name, setProgress]);
|
||||
|
||||
@@ -118,7 +127,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
try {
|
||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
||||
);
|
||||
const updatedFiles = [
|
||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
||||
@@ -126,13 +135,13 @@ async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
];
|
||||
await AsyncStorage.setItem(
|
||||
"downloaded_files",
|
||||
JSON.stringify(updatedFiles),
|
||||
JSON.stringify(updatedFiles)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating downloaded files:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`Failed to update downloaded files for item: ${item.Name}`,
|
||||
`Failed to update downloaded files for item: ${item.Name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.7.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.8.1" },
|
||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||
})
|
||||
);
|
||||
|
||||
@@ -179,7 +179,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId || !api || !user) return;
|
||||
if (!deviceId || !api?.accessToken) return;
|
||||
|
||||
const url = `wss://${api?.basePath
|
||||
.replace("https://", "")
|
||||
|
||||
@@ -13,7 +13,9 @@ export const sortOptions: {
|
||||
{ key: "SortName", value: "Name" },
|
||||
{ key: "CommunityRating", value: "Community Rating" },
|
||||
{ key: "CriticRating", value: "Critics Rating" },
|
||||
{ key: "DateLastContentAdded", value: "Content Added" },
|
||||
{ key: "DateCreated", value: "Date Added" },
|
||||
// Only works for shows (last episode added) keeping for future ref.
|
||||
// { key: "DateLastContentAdded", value: "Content Added" },
|
||||
{ key: "DatePlayed", value: "Date Played" },
|
||||
{ key: "PlayCount", value: "Play Count" },
|
||||
{ key: "ProductionYear", value: "Production Year" },
|
||||
@@ -23,7 +25,8 @@ export const sortOptions: {
|
||||
{ key: "StartDate", value: "Start Date" },
|
||||
{ key: "IsUnplayed", value: "Is Unplayed" },
|
||||
{ key: "IsPlayed", value: "Is Played" },
|
||||
{ key: "VideoBitRate", value: "Video Bit Rate" },
|
||||
// Broken in JF
|
||||
// { key: "VideoBitRate", value: "Video Bit Rate" },
|
||||
{ key: "AirTime", value: "Air Time" },
|
||||
{ key: "Studio", value: "Studio" },
|
||||
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const playingAtom = atom(false);
|
||||
export const fullScreenAtom = atom(false);
|
||||
export const showCurrentlyPlayingBarAtom = atom(false);
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
} | null>(null);
|
||||
@@ -2,6 +2,28 @@ import { atom, useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
|
||||
export type DownloadOption = {
|
||||
label: string;
|
||||
value: DownloadQuality;
|
||||
};
|
||||
|
||||
export const DownloadOptions: DownloadOption[] = [
|
||||
{
|
||||
label: "Original quality",
|
||||
value: "original",
|
||||
},
|
||||
{
|
||||
label: "High quality",
|
||||
value: "high",
|
||||
},
|
||||
{
|
||||
label: "Small file size",
|
||||
value: "low",
|
||||
},
|
||||
];
|
||||
|
||||
type Settings = {
|
||||
autoRotate?: boolean;
|
||||
forceLandscapeInVideoPlayer?: boolean;
|
||||
@@ -13,6 +35,7 @@ type Settings = {
|
||||
searchEngine: "Marlin" | "Jellyfin";
|
||||
marlinServerUrl?: string;
|
||||
openInVLC?: boolean;
|
||||
downloadQuality?: DownloadOption;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,23 +46,31 @@ type Settings = {
|
||||
*
|
||||
*/
|
||||
|
||||
// Utility function to load settings from AsyncStorage
|
||||
const loadSettings = async (): Promise<Settings> => {
|
||||
const jsonValue = await AsyncStorage.getItem("settings");
|
||||
return jsonValue != null
|
||||
? JSON.parse(jsonValue)
|
||||
: {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
openFullScreenVideoPlayerByDefault: false,
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
mediaListCollectionIds: [],
|
||||
searchEngine: "Jellyfin",
|
||||
marlinServerUrl: "",
|
||||
openInVLC: false,
|
||||
};
|
||||
const defaultValues: Settings = {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
openFullScreenVideoPlayerByDefault: false,
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
mediaListCollectionIds: [],
|
||||
searchEngine: "Jellyfin",
|
||||
marlinServerUrl: "",
|
||||
openInVLC: false,
|
||||
downloadQuality: DownloadOptions[0],
|
||||
};
|
||||
|
||||
try {
|
||||
const jsonValue = await AsyncStorage.getItem("settings");
|
||||
const loadedValues: Partial<Settings> =
|
||||
jsonValue != null ? JSON.parse(jsonValue) : {};
|
||||
|
||||
return { ...defaultValues, ...loadedValues };
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
return defaultValues;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to save settings to AsyncStorage
|
||||
|
||||
Reference in New Issue
Block a user