forked from Ninjalama/streamyfin_mirror
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e65ed3db0e | ||
|
|
cb9dfe2c83 | ||
|
|
bc4b07c76b | ||
|
|
150eb1809f | ||
|
|
8afe7dc5e4 | ||
|
|
855e00a676 | ||
|
|
5289c0519f | ||
|
|
4b1eb2218f | ||
|
|
a99e7b950e | ||
|
|
51fc2a0edb | ||
|
|
3a13503d1d | ||
|
|
2fdf90ab4b | ||
|
|
6fed0c1c77 | ||
|
|
ee7ff3444e | ||
|
|
dec175a300 | ||
|
|
27099d3184 | ||
|
|
bfad77dd7a | ||
|
|
74a33f8f82 | ||
|
|
75de878618 | ||
|
|
9628285701 |
21
README.md
21
README.md
@@ -12,10 +12,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
|
||||
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||
- 📥 Download media (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||
|
||||
## 🧪 Experimental Features
|
||||
|
||||
@@ -25,15 +26,21 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
## 🛠️ Beta testing (iOS/Android)
|
||||
## Get it now
|
||||
|
||||
## TestFlight
|
||||
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
|
||||
<img height=75 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
|
||||
</a>
|
||||
|
||||
### TestFlight
|
||||
|
||||
Get the latest updates by using the TestFlight version of the app.
|
||||
|
||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
||||
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
|
||||
</a>
|
||||
|
||||
## Play Store Open Beta
|
||||
### Play Store Open Beta
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
|
||||
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
|
||||
|
||||
33
app.json
33
app.json
@@ -2,8 +2,8 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.3.4",
|
||||
"orientation": "portrait",
|
||||
"version": "0.4.1",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
"userInterfaceStyle": "dark",
|
||||
@@ -13,25 +13,33 @@
|
||||
"backgroundColor": "#29164B"
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"requireFullScreen": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true,
|
||||
"NSExceptionDomains": {
|
||||
"*": {
|
||||
"NSExceptionAllowsInsecureHTTPLoads": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 12,
|
||||
"orientation": "default",
|
||||
"androidNavigationBar": {
|
||||
"visible": true,
|
||||
"barStyle": "dark-content",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"versionCode": 14,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
@@ -98,7 +106,8 @@
|
||||
{
|
||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||
}
|
||||
]
|
||||
],
|
||||
"expo-video"
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -16,14 +16,18 @@ import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
|
||||
const { data: downloadedFiles, isLoading } = useQuery({
|
||||
queryKey: ["downloaded_files"],
|
||||
queryKey: ["downloaded_files", process?.item.Id],
|
||||
queryFn: async () =>
|
||||
JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
) as BaseItemDto[],
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const movies = useMemo(
|
||||
@@ -41,8 +45,6 @@ const downloads: React.FC = () => {
|
||||
return Object.values(series);
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const [process, setProcess] = useAtom(runningProcesses);
|
||||
|
||||
const eta = useMemo(() => {
|
||||
const length = process?.item?.RunTimeTicks || 0;
|
||||
|
||||
@@ -77,7 +79,7 @@ const downloads: React.FC = () => {
|
||||
<View>
|
||||
<Text className="font-semibold">{process.item.Name}</Text>
|
||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-red-600">
|
||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||
<Text className="text-xs">
|
||||
{process.progress.toFixed(0)}%
|
||||
</Text>
|
||||
@@ -97,7 +99,7 @@ const downloads: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
<View
|
||||
className={`
|
||||
absolute bottom-0 left-0 h-1 bg-red-600
|
||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
||||
`}
|
||||
style={{
|
||||
width: process.progress
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
@@ -36,6 +36,10 @@ import ios12 from "@/utils/profiles/ios12";
|
||||
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
@@ -201,7 +205,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col px-4 mb-4 pt-4">
|
||||
<View className="flex flex-col px-4 pt-4">
|
||||
<View className="flex flex-col">
|
||||
{item.Type === "Episode" ? (
|
||||
<>
|
||||
@@ -218,7 +222,6 @@ const page: React.FC = () => {
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
<View>
|
||||
<View className="flex flex-row items-center self-center">
|
||||
@@ -243,7 +246,6 @@ const page: React.FC = () => {
|
||||
<Text className="text-center font-bold text-2xl mr-2">
|
||||
{item?.Name}
|
||||
</Text>
|
||||
<PlayedStatus item={item} />
|
||||
</View>
|
||||
<Text className="text-center opacity-50">
|
||||
{item?.ProductionYear}
|
||||
@@ -253,14 +255,17 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackUrl && (
|
||||
{playbackUrl ? (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
) : (
|
||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
||||
)}
|
||||
<PlayedStatus item={item} />
|
||||
<Chromecast />
|
||||
</View>
|
||||
<Text>{item.Overview}</Text>
|
||||
</View>
|
||||
<View className="flex flex-col p-4">
|
||||
<View className="flex flex-col p-4 w-full">
|
||||
<View className="flex flex-row items-center space-x-2 w-full">
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
@@ -277,7 +282,16 @@ const page: React.FC = () => {
|
||||
selected={selectedSubtitleStream}
|
||||
/>
|
||||
</View>
|
||||
<PlayButton item={item} chromecastReady={false} onPress={onPressPlay} />
|
||||
<View className="flex flex-row items-center justify-between w-full">
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={false}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
<NextEpisodeButton item={item} className="ml-2" />
|
||||
</View>
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
|
||||
@@ -38,7 +38,7 @@ const page: React.FC = () => {
|
||||
itemId: seriesId,
|
||||
}),
|
||||
enabled: !!seriesId && !!api,
|
||||
staleTime: 0,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const backdropUrl = useMemo(
|
||||
|
||||
@@ -55,25 +55,8 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const parsedServerURL = useMemo(() => {
|
||||
let parsedServerURL = serverURL.trim();
|
||||
|
||||
if (parsedServerURL) {
|
||||
parsedServerURL = parsedServerURL.endsWith("/")
|
||||
? parsedServerURL.replace("/", "")
|
||||
: parsedServerURL;
|
||||
parsedServerURL = parsedServerURL.startsWith("http")
|
||||
? parsedServerURL
|
||||
: "http://" + parsedServerURL;
|
||||
|
||||
return parsedServerURL;
|
||||
}
|
||||
|
||||
return "";
|
||||
}, [serverURL]);
|
||||
|
||||
const handleConnect = (url: string) => {
|
||||
setServer({ address: url });
|
||||
setServer({ address: url.trim() });
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
@@ -165,9 +148,7 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button onPress={() => handleConnect(parsedServerURL)}>
|
||||
Connect
|
||||
</Button>
|
||||
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
BIN
assets/Download_on_the_App_Store_Badge.png
Normal file
BIN
assets/Download_on_the_App_Store_Badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -7,7 +7,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disabled?: boolean;
|
||||
children?: string;
|
||||
children?: string | ReactNode;
|
||||
loading?: boolean;
|
||||
color?: "purple" | "red" | "black";
|
||||
iconRight?: ReactNode;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import React, { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
CastButton,
|
||||
useCastDevice,
|
||||
@@ -30,5 +31,9 @@ export const Chromecast: React.FC<Props> = () => {
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
|
||||
return (
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<CastButton style={{ tintColor: "white", height: 48, width: 48 }} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
}}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
|
||||
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
|
||||
></View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,23 +7,13 @@ import {
|
||||
import { Text } from "./common/Text";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Video, {
|
||||
OnProgressData,
|
||||
SelectedTrack,
|
||||
SelectedTrackType,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios12 from "@/utils/profiles/ios12";
|
||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||
import Animated, {
|
||||
@@ -35,7 +25,6 @@ import { useRouter, useSegments } from "expo-router";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { Image } from "expo-image";
|
||||
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
@@ -43,13 +32,10 @@ export const currentlyPlayingItemAtom = atom<{
|
||||
} | null>(null);
|
||||
|
||||
export const CurrentlyPlayingBar: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const client = useRemoteMediaClient();
|
||||
const queryClient = useQueryClient();
|
||||
const segments = useSegments();
|
||||
|
||||
@@ -229,14 +215,19 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
{cp.playbackUrl && (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
allowsExternalPlayback
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
allowsExternalPlayback={true}
|
||||
playInBackground={true}
|
||||
playWhenInactive={true}
|
||||
playInBackground={true}
|
||||
showNotificationControls={true}
|
||||
ignoreSilentSwitch="ignore"
|
||||
controls={false}
|
||||
poster={backdropUrl ? backdropUrl : undefined}
|
||||
pictureInPicture={true}
|
||||
poster={
|
||||
backdropUrl && item?.Type === "Audio"
|
||||
? backdropUrl
|
||||
: undefined
|
||||
}
|
||||
paused={paused}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
subtitleStyle={{
|
||||
@@ -250,8 +241,14 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
onBuffer={(e) =>
|
||||
e.isBuffering ? console.log("Buffering...") : null
|
||||
}
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
play();
|
||||
onPlaybackStateChanged={(e) => {
|
||||
if (e.isPlaying) {
|
||||
setPaused(false);
|
||||
} else if (e.isSeeking) {
|
||||
return;
|
||||
} else {
|
||||
setPaused(true);
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.log(e);
|
||||
@@ -261,15 +258,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
||||
);
|
||||
}}
|
||||
renderLoader={
|
||||
item?.Type === "Video" && (
|
||||
item?.Type !== "Audio" && (
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
subtitleStyle={{
|
||||
fontSize: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -64,12 +64,16 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
}, [process]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ActivityIndicator size={"small"} color={"white"} />;
|
||||
return (
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||
return (
|
||||
<View style={{ opacity: 0.5 }}>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
);
|
||||
@@ -77,21 +81,22 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
|
||||
if (process && process.item.Id !== item.Id!) {
|
||||
return (
|
||||
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
<TouchableOpacity onPress={() => {}}>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center opacity-50">
|
||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{process ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
cancelRemuxing();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
if (process) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
{process.progress === 0 ? (
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
) : (
|
||||
@@ -118,27 +123,32 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
<Text>{process.speed.toFixed(2)}x</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
) : downloaded ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(
|
||||
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download" size={26} color="#16a34a" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// downloadFile();
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else if (downloaded) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/downloads");
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
56
components/NewVideoPlayer.tsx
Normal file
56
components/NewVideoPlayer.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useVideoPlayer, VideoView } from "expo-video";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
PixelRatio,
|
||||
StyleSheet,
|
||||
View,
|
||||
Button,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
|
||||
const videoSource =
|
||||
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
|
||||
interface Props {
|
||||
videoSource: string;
|
||||
}
|
||||
|
||||
export const NewVideoPlayer: React.FC<Props> = ({ videoSource }) => {
|
||||
const ref = useRef<VideoView | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(true);
|
||||
const player = useVideoPlayer(videoSource, (player) => {
|
||||
player.loop = true;
|
||||
player.play();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = player.addListener("playingChange", (isPlaying) => {
|
||||
setIsPlaying(isPlaying);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [player]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
ref.current?.enterFullscreen();
|
||||
}}
|
||||
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||
`}
|
||||
>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
player={player}
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ type VideoPlayerProps = {
|
||||
onChangePlaybackURL: (url: string | null) => void;
|
||||
};
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
itemId,
|
||||
onChangePlaybackURL,
|
||||
}) => {
|
||||
@@ -89,7 +89,9 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<View className="flex-1 overflow-hidden bg-black">{children}</View>
|
||||
<View className="flex-1 overflow-hidden bg-black pb-24">
|
||||
{children}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "./Button";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
|
||||
import { useAtom } from "jotai";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
|
||||
type Props = {
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
onPress: () => void;
|
||||
chromecastReady: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
onPress,
|
||||
chromecastReady,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -27,6 +25,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Button>
|
||||
|
||||
@@ -47,7 +47,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={26} color="white" />
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle" size={26} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
@@ -61,7 +63,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
|
||||
<View className="rounded h-12 aspect-square flex items-center justify-center">
|
||||
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -13,6 +13,22 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
// const fetchFileSize = async () => {
|
||||
// try {
|
||||
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
// const info = await FileSystem.getInfoAsync(filePath);
|
||||
// return info.exists ? info.size : null;
|
||||
// } catch (e) {
|
||||
// console.log(e);
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
|
||||
// const { data: fileSize } = useQuery({
|
||||
// queryKey: ["fileSize", item?.Id],
|
||||
// queryFn: fetchFileSize,
|
||||
// });
|
||||
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
item,
|
||||
@@ -43,6 +59,12 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
<Text className=" text-xs opacity-50">
|
||||
Episode {item.IndexNumber}
|
||||
</Text>
|
||||
{/* <Text className=" text-xs opacity-50">
|
||||
Size:{" "}
|
||||
{fileSize
|
||||
? `${(fileSize / 1000000).toFixed(0)} MB`
|
||||
: "Calculating..."}{" "}
|
||||
</Text> */}
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content
|
||||
|
||||
@@ -9,11 +9,23 @@ import { useCallback } from "react";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
// const fetchFileSize = async () => {
|
||||
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
// const info = await FileSystem.getInfoAsync(filePath);
|
||||
// return info.exists ? info.size : null;
|
||||
// };
|
||||
|
||||
// const { data: fileSize } = useQuery({
|
||||
// queryKey: ["fileSize", item?.Id],
|
||||
// queryFn: fetchFileSize,
|
||||
// });
|
||||
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
item,
|
||||
@@ -41,11 +53,17 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
||||
>
|
||||
<Text className=" font-bold">{item.Name}</Text>
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<View className="flex flex-col">
|
||||
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
|
||||
<Text className=" text-xs opacity-50">
|
||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
||||
</Text>
|
||||
{/* <Text className=" text-xs opacity-50">
|
||||
Size:{" "}
|
||||
{fileSize
|
||||
? `${(fileSize / 1000000).toFixed(0)} MB`
|
||||
: "Calculating..."}{" "}
|
||||
</Text>*/}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
107
components/series/NextEpisodeButton.tsx
Normal file
107
components/series/NextEpisodeButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Button } from "../Button";
|
||||
import { useRouter } from "expo-router";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof Button> {
|
||||
item: BaseItemDto;
|
||||
type?: "next" | "previous";
|
||||
}
|
||||
|
||||
export const NextEpisodeButton: React.FC<Props> = ({
|
||||
item,
|
||||
type = "next",
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
// const { data: seasons } = useQuery({
|
||||
// queryKey: ["seasons", item.SeriesId],
|
||||
// queryFn: async () => {
|
||||
// if (
|
||||
// !api ||
|
||||
// !user?.Id ||
|
||||
// !item?.Id ||
|
||||
// !item?.SeriesId ||
|
||||
// !item?.IndexNumber
|
||||
// )
|
||||
// return [];
|
||||
|
||||
// const response = await getItemsApi(api).getItems({
|
||||
// parentId: item?.SeriesId,
|
||||
// });
|
||||
|
||||
// console.log("seasons ~", type, response.data);
|
||||
|
||||
// return (response.data.Items as BaseItemDto[]) ?? [];
|
||||
// },
|
||||
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
// });
|
||||
|
||||
// const nextSeason = useMemo(() => {
|
||||
// if (!seasons) return null;
|
||||
// const currentSeasonIndex = seasons.findIndex(
|
||||
// (season) => season.Id === item.SeasonId,
|
||||
// );
|
||||
|
||||
// if (currentSeasonIndex === seasons.length - 1) return null;
|
||||
|
||||
// return seasons[currentSeasonIndex + 1];
|
||||
// }, [seasons]);
|
||||
|
||||
const { data: nextEpisode } = useQuery({
|
||||
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||
queryFn: async () => {
|
||||
if (
|
||||
!api ||
|
||||
!user?.Id ||
|
||||
!item?.Id ||
|
||||
!item?.ParentId ||
|
||||
!item?.IndexNumber
|
||||
)
|
||||
return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
parentId: item?.ParentId,
|
||||
limit: 1,
|
||||
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
|
||||
});
|
||||
|
||||
console.log("NextEpisode ~", type, response.data);
|
||||
|
||||
return (response.data.Items?.[0] as BaseItemDto) || null;
|
||||
},
|
||||
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||
});
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
if (!nextEpisode) return true;
|
||||
if (nextEpisode.Id === item.Id) return true;
|
||||
return false;
|
||||
}, [nextEpisode, type]);
|
||||
|
||||
if (item.Type !== "Episode") return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => router.replace(`/items/${nextEpisode?.Id}/page`)}
|
||||
className={`h-12 aspect-square`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{type === "next" ? (
|
||||
<Ionicons name="chevron-forward" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="chevron-back" size={24} color="white" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -12,5 +12,5 @@ export const Colors = {
|
||||
tint: tintColorDark,
|
||||
icon: "#9BA1A6",
|
||||
tabIconDefault: "#9BA1A6",
|
||||
tabIconSelected: "#EE4B2B",
|
||||
tabIconSelected: "#9333ea",
|
||||
};
|
||||
|
||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"channel": "0.3.4",
|
||||
"channel": "0.4.1",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production-apk": {
|
||||
"channel": "0.3.4",
|
||||
"channel": "0.4.1",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"image": "latest"
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.22",
|
||||
"expo-video": "^1.2.4",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
|
||||
Reference in New Issue
Block a user