This commit is contained in:
Fredrik Burmester
2024-08-06 21:16:37 +02:00
parent 165a9ddde7
commit 57e33428dc
29 changed files with 1113 additions and 296 deletions

View File

@@ -32,7 +32,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "purple":
return "bg-purple-600 active:bg-purple-700";
case "red":
return "bg-red-500";
return "bg-red-600";
case "black":
return "bg-black border border-neutral-900";
}

View File

@@ -1,8 +1,12 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin";
import {
getPlaybackInfo,
useDownloadMedia,
useRemuxHlsToMp4,
} from "@/utils/jellyfin";
import Ionicons from "@expo/vector-icons/Ionicons";
import { BaseItemDto } 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";
@@ -14,9 +18,13 @@ import { Text } from "./common/Text";
type DownloadProps = {
item: BaseItemDto;
playbackURL: string;
};
export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
export const DownloadItem: React.FC<DownloadProps> = ({
item,
playbackURL,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
@@ -24,6 +32,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackURL, item);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
@@ -34,6 +44,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
const source = playbackInfo.MediaSources?.[0];
console.log("Source:", JSON.stringify(source));
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
@@ -80,22 +92,34 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
{process ? (
<TouchableOpacity
onPress={() => {
cancelDownload();
cancelRemuxing();
}}
className="relative"
className="flex flex-row items-center"
>
<View className="-rotate-45">
<ProgressCircle
size={26}
fill={process.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
</View>
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
<Text className="text-[6px]">{process.progress.toFixed(0)}%</Text>
<View className="relative">
<View className="-rotate-45">
<ProgressCircle
size={26}
fill={process.progress}
width={3}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
</View>
{process.progress > 0 ? (
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
<Text className="text-[6px]">
{process.progress.toFixed(0)}%
</Text>
</View>
) : null}
</View>
{process?.speed && (process?.speed || 0) > 0 ? (
<View className="ml-2">
<Text>{process.speed.toFixed(2)}x</Text>
</View>
) : null}
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
@@ -110,7 +134,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item }) => {
) : (
<TouchableOpacity
onPress={() => {
downloadFile();
// downloadFile();
startRemuxing();
}}
>
<Ionicons name="cloud-download-outline" size={26} color="white" />

View File

@@ -1,4 +1,9 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
chromecastProfile,
iosProfile,
iOSProfile_2,
} from "@/utils/device-profiles";
import {
getStreamUrl,
getUserItemData,
@@ -17,7 +22,8 @@ import React, {
useRef,
useState,
} from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
@@ -28,13 +34,12 @@ import Video, {
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import GoogleCast from "react-native-google-cast";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { chromecastProfile, iosProfile } from "@/utils/device-profiles";
import iosFmp4 from "../utils/profiles/iosFmp4";
import ios12 from "../utils/profiles/ios12";
type VideoPlayerProps = {
itemId: string;
onChangePlaybackURL: (url: string | null) => void;
};
const BITRATES = [
@@ -56,7 +61,10 @@ const BITRATES = [
},
];
export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
itemId,
onChangePlaybackURL,
}) => {
const videoRef = useRef<VideoRef | null>(null);
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
@@ -108,11 +116,13 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosProfile,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
});
console.log("Transcode URL:", url);
onChangePlaybackURL(url);
return url;
},
enabled: !!sessionData,
@@ -165,6 +175,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
}, [item]);
const [hidePlayer, setHidePlayer] = useState(true);
const enableVideo = useMemo(() => {
return (
playbackURL !== undefined &&
@@ -220,13 +232,11 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
return (
<View>
{enableVideo === true &&
playbackURL !== null &&
playbackURL !== undefined ? (
{enableVideo === true ? (
<Video
style={{ width: 0, height: 0 }}
source={{
uri: playbackURL,
uri: playbackURL!,
isNetwork: true,
startPosition,
}}
@@ -242,7 +252,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
onFullscreenPlayerDidDismiss={() => {
videoRef.current?.pause();
setPaused(true);
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
@@ -260,19 +269,16 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
setHidePlayer(true);
}}
onFullscreenPlayerDidPresent={() => {
play();
}}
paused={paused}
onPlaybackStateChanged={(e: OnPlaybackStateChangedData) => {}}
bufferConfig={{
maxBufferMs: Infinity,
minBufferMs: 1000 * 60 * 2,
bufferForPlaybackMs: 1000,
backBufferDurationMs: 30 * 1000,
}}
ignoreSilentSwitch="ignore"
preferredForwardBufferDuration={1}
/>
) : null}
<View className="flex flex-row items-center justify-between">
@@ -319,8 +325,12 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
onPress={() => {
if (chromecastReady) {
cast();
} else if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
} else {
setHidePlayer(false);
setTimeout(() => {
if (!videoRef.current) return;
videoRef.current.presentFullscreenPlayer();
}, 1000);
}
}}
iconRight={

View File

@@ -1,23 +1,68 @@
import { TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { TouchableOpacity } from "react-native";
import * as ContextMenu from "zeego/context-menu";
import { Text } from "../common/Text";
import { useFiles } from "@/utils/files/useFiles";
import * as Haptics from 'expo-haptics';
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const open = () => {
router.back();
const { deleteFile } = useFiles();
const openFile = () => {
router.push(
`/(auth)/player/offline/page?url=${item.Id}.${item.MediaSources?.[0].Container}&itemId=${item.Id}`
);
};
const options = [
{
label: "Delete",
onSelect: (id: string) => {
deleteFile(id)
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
},
destructive: true,
},
];
return (
<TouchableOpacity
onPress={open}
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<Text className=" text-xs opacity-50">Episode {item.IndexNumber}</Text>
</TouchableOpacity>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={openFile}
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<Text className=" text-xs opacity-50">
Episode {item.IndexNumber}
</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
>
{options.map((i) => (
<ContextMenu.Item
onSelect={() => {
i.onSelect(item.Id!);
}}
key={i.label}
destructive={i.destructive}
>
<ContextMenu.ItemTitle
style={{
color: "red",
}}
>
{i.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -1,18 +1,68 @@
import { View } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { Text } from "../common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runtimeTicksToMinutes } from "@/utils/time";
import * as ContextMenu from "zeego/context-menu";
import { router } from "expo-router";
import { useFiles } from "@/utils/files/useFiles";
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const openFile = () => {
router.push(
`/(auth)/player/offline/page?url=${item.Id}.${item.MediaSources?.[0].Container}&itemId=${item.Id}`
);
};
const options = [
{
label: "Delete",
onSelect: (id: string) => deleteFile(id),
destructive: true,
},
];
return (
<View className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4">
<Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</View>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={openFile}
className="bg-neutral-800 border border-neutral-900 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions={false}
collisionPadding={0}
loop={false}
>
{options.map((i) => (
<ContextMenu.Item
onSelect={() => {
i.onSelect(item.Id!);
}}
key={i.label}
destructive={i.destructive}
>
<ContextMenu.ItemTitle
style={{
color: "red",
}}
>
{i.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -38,8 +38,8 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
{seasonItems[0].SeasonName}
</Text>
{seasonItems.map((item, index) => (
<View className="mb-2">
<EpisodeCard item={item} key={item.Id} />
<View className="mb-2" key={index}>
<EpisodeCard item={item} />
</View>
))}
</View>