This commit is contained in:
Fredrik Burmester
2024-08-06 08:18:17 +02:00
parent 2aa30ab4ca
commit 382e70cf8e
55 changed files with 4135 additions and 397 deletions

View File

@@ -9,8 +9,10 @@ interface ButtonProps {
disabled?: boolean;
children?: string;
loading?: boolean;
color?: "purple" | "red";
color?: "purple" | "red" | "black";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
}
export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
@@ -21,14 +23,18 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
loading = false,
color = "purple",
iconRight,
iconLeft,
children,
justify = "center",
}) => {
const colorClasses = useMemo(() => {
switch (color) {
case "purple":
return "bg-purple-600 active:bg-purple-700";
case "red":
return "bg-red-500 active:bg-red-600";
return "bg-red-500";
case "black":
return "bg-black border border-neutral-900";
}
}, [color]);
@@ -51,18 +57,24 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{loading ? (
<ActivityIndicator color={"white"} size={24} />
) : (
<View className="flex flex-row items-center">
<View
className={`
flex flex-row items-center justify-between w-full
${justify === "between" ? "justify-between" : "justify-center"}`}
>
{iconLeft ? iconLeft : <View className="w-4"></View>}
<Text
className={`
text-white font-bold text-base
${disabled ? "text-gray-300" : ""}
${textClassName}
${iconRight ? "mr-2" : ""}
${iconRight ? "mr-2" : ""}
${iconLeft ? "ml-2" : ""}
`}
>
{children}
</Text>
{iconRight}
{iconRight ? iconRight : <View className="w-4"></View>}
</View>
)}
</TouchableOpacity>

69
components/Chromecast.tsx Normal file
View File

@@ -0,0 +1,69 @@
import { Feather, FontAwesome, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect } from "react";
import { TouchableOpacity, View } from "react-native";
import {
CastButton,
useCastDevice,
useDevices,
useRemoteMediaClient,
} from "react-native-google-cast";
import GoogleCast from "react-native-google-cast";
import { Text } from "./common/Text";
type Props = {
item?: BaseItemDto | null;
startTimeTicks?: number | null;
};
export const Chromecast: React.FC<Props> = ({ item, startTimeTicks }) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
useEffect(() => {
(async () => {
if (!discoveryManager) {
console.log("No discoveryManager client");
return;
}
await discoveryManager.startDiscovery();
const started = await discoveryManager.isRunning();
console.log("started", started);
console.log({
devices,
castDevice,
sessionManager,
});
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
const cast = () => {
if (!client) {
console.log("No chromecast client");
return;
}
client.loadMedia({
mediaInfo: {
contentUrl:
"https://commondatastorage.googleapis.com/gtv-videos-bucket/CastVideos/mp4/BigBuckBunny.mp4",
contentType: "video/mp4",
metadata: {
type: item?.Type === "Episode" ? "tvShow" : "movie",
title: item?.Name || "",
subtitle: item?.Overview || "",
},
streamDuration: Math.floor((item?.RunTimeTicks || 0) / 10000),
},
startTime: Math.floor((startTimeTicks || 0) / 10000),
});
};
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
};

View File

@@ -1,13 +1,10 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop } from "@/utils/jellyfin";
import { Ionicons } from "@expo/vector-icons";
import { getPrimaryImage } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useState } from "react";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = {
@@ -19,12 +16,16 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getBackdrop(api, item),
enabled: !!api && !!item.Id,
staleTime: 60 * 60 * 24 * 7,
});
const url = useMemo(
() =>
getPrimaryImage({
api,
item,
quality: 70,
width: 300,
}),
[item]
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
@@ -47,7 +48,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
contentFit="cover"
className="w-full h-full"
/>
<WatchedIndicator item={item} />
{!progress && <WatchedIndicator item={item} />}
{progress > 0 && (
<>
<View

View File

@@ -1,17 +1,16 @@
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 Ionicons from "@expo/vector-icons/Ionicons";
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 { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useQuery } from "@tanstack/react-query";
type DownloadProps = {
item: BaseItemDto;

View File

@@ -1,10 +1,10 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getBackdrop, getPrimaryImageById } from "@/utils/jellyfin";
import { getPrimaryImage, getPrimaryImageById } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useState } from "react";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
@@ -19,12 +19,14 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", item.Id],
queryFn: async () => getPrimaryImageById(api, item.Id),
enabled: !!api && !!item.Id,
staleTime: Infinity,
});
const url = useMemo(
() =>
getPrimaryImage({
api,
item,
}),
[item]
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0

View File

@@ -0,0 +1,96 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import type { PropsWithChildren, ReactElement } from "react";
import { TouchableOpacity, View } from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const HEADER_HEIGHT = 400;
type Props = PropsWithChildren<{
headerImage: ReactElement;
logo?: ReactElement;
}>;
export const ParallaxScrollView: React.FC<Props> = ({
children,
headerImage,
logo,
}: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1]
),
},
],
};
});
const inset = useSafeAreaInsets();
return (
<View className="flex-1">
<Animated.ScrollView
style={{
position: "relative",
}}
ref={scrollRef}
scrollEventThrottle={16}
>
<TouchableOpacity
onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{
top: inset.top,
}}
>
<Ionicons
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
/>
</TouchableOpacity>
{logo && (
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
{logo}
</View>
)}
<Animated.View
style={[
{
height: HEADER_HEIGHT,
backgroundColor: "black",
},
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<View className="flex-1 overflow-hidden bg-black">{children}</View>
</Animated.ScrollView>
</View>
);
};

View File

@@ -0,0 +1,49 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type PosterProps = {
id?: string;
showProgress?: boolean;
};
const ParentPoster: React.FC<PosterProps> = ({ id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() => `${api?.basePath}/Items/${id}/Images/Primary`,
[id]
);
if (!url || !id)
return (
<View
className="border border-neutral-900"
style={{
aspectRatio: "10/15",
}}
></View>
);
return (
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
}}
/>
</View>
);
};
export default ParentPoster;

View File

@@ -58,7 +58,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
onPress={() => {
markAsPlayed({
api: api,
itemId: item?.Id,
item: item,
userId: user?.Id,
});
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

View File

@@ -1,26 +1,18 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageById } from "@/utils/jellyfin";
import { useQuery } from "@tanstack/react-query";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { View } from "react-native";
type PosterProps = {
itemId?: string | null;
item?: BaseItemDto | BaseItemPerson | null;
url?: string | null;
showProgress?: boolean;
};
const Poster: React.FC<PosterProps> = ({ itemId }) => {
const [api] = useAtom(apiAtom);
const { data: url } = useQuery({
queryKey: ["backdrop", itemId],
queryFn: async () => getPrimaryImageById(api, itemId),
enabled: !!api && !!itemId,
staleTime: Infinity,
});
if (!url || !itemId)
const Poster: React.FC<PosterProps> = ({ item, url }) => {
if (!url || !item)
return (
<View
className="border border-neutral-900"
@@ -33,8 +25,8 @@ const Poster: React.FC<PosterProps> = ({ itemId }) => {
return (
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
key={itemId}
id={itemId}
key={item.Id}
id={item.Id}
source={{
uri: url,
}}

View File

@@ -8,9 +8,15 @@ import {
import { runtimeTicksToMinutes } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import Video, {
OnBufferData,
@@ -22,6 +28,10 @@ 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";
type VideoPlayerProps = {
itemId: string;
@@ -50,10 +60,16 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const videoRef = useRef<VideoRef | null>(null);
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [progress, setProgress] = useState(0);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const { data: item } = useQuery({
queryKey: ["item", itemId],
queryFn: async () =>
@@ -81,7 +97,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
});
const { data: playbackURL } = useQuery({
queryKey: ["playbackUrl", itemId, maxBitrate],
queryKey: ["playbackUrl", itemId, maxBitrate, castDevice],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
@@ -92,6 +108,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosProfile,
});
console.log("Transcode URL:", url);
@@ -102,21 +119,22 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
staleTime: 0,
});
const [progress, setProgress] = useState(0);
const onProgress = useCallback(
({ currentTime, playableDuration, seekableDuration }: OnProgressData) => {
if (!currentTime || !sessionData?.PlaySessionId) return;
if (paused) return;
const onProgress = ({
currentTime,
playableDuration,
seekableDuration,
}: OnProgressData) => {
setProgress(currentTime * 10000000);
reportPlaybackProgress({
api,
itemId: itemId,
positionTicks: currentTime * 10000000,
sessionId: sessionData?.PlaySessionId,
});
};
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: itemId,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, item, api, paused]
);
const onSeek = ({
currentTime,
@@ -139,6 +157,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
const play = () => {
if (videoRef.current) {
videoRef.current.resume();
setPaused(false);
}
};
@@ -146,12 +165,6 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000);
}, [item]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.pause();
}
}, []);
const enableVideo = useMemo(() => {
return (
playbackURL !== undefined &&
@@ -162,6 +175,45 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
);
}, [playbackURL, item, startPosition, sessionData]);
const cast = useCallback(() => {
if (client === null) {
console.log("no client ");
return;
}
if (!playbackURL) {
console.log("no playback url");
return;
}
if (!item) {
console.log("no item");
return;
}
client.loadMedia({
mediaInfo: {
contentUrl: playbackURL,
contentType: "video/mp4",
metadata: {
type: item?.Type === "Episode" ? "tvShow" : "movie",
title: item?.Name || "",
subtitle: item?.Overview || "",
},
streamDuration: Math.floor((item?.RunTimeTicks || 0) / 10000),
},
startTime: Math.floor(
(item?.UserData?.PlaybackPositionTicks || 0) / 10000
),
});
}, [item, client, playbackURL]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.pause();
}
}, []);
return (
<View>
{enableVideo === true &&
@@ -185,6 +237,19 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
onProgress={(e) => onProgress(e)}
onFullscreenPlayerDidDismiss={() => {
videoRef.current?.pause();
setPaused(true);
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
if (progress === 0) return;
reportPlaybackStopped({
api,
itemId: item?.Id,
@@ -203,6 +268,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
bufferForPlaybackMs: 1000,
backBufferDurationMs: 30 * 1000,
}}
ignoreSilentSwitch="ignore"
/>
) : null}
<View className="flex flex-row items-center justify-between">
@@ -247,7 +313,9 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({ itemId }) => {
<Button
disabled={!enableVideo}
onPress={() => {
if (videoRef.current) {
if (castDevice?.deviceId && item) {
cast();
} else if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}}

View File

@@ -1,11 +1,15 @@
import { getPrimaryImage } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../Poster";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
@@ -13,7 +17,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
data={item.People}
renderItem={(item, index) => (
<TouchableOpacity key={item.Id} className="flex flex-col w-32">
<Poster itemId={item.Id} />
<Poster item={item} url={getPrimaryImage({ api, item })} />
<Text className="mt-2">{item.Name}</Text>
<Text className="text-xs opacity-50">{item.Role}</Text>
</TouchableOpacity>

View File

@@ -1,5 +1,8 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImage, getPrimaryImageById } from "@/utils/jellyfin";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import Poster from "../Poster";
@@ -7,6 +10,8 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
@@ -18,7 +23,10 @@ export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
className="flex flex-col space-y-2 w-32"
>
<Poster itemId={item.ParentBackdropItemId} />
<Poster
item={item}
url={getPrimaryImageById({ api, id: item.ParentId })}
/>
<Text>{item.SeriesName}</Text>
</TouchableOpacity>
)}

View File

@@ -114,7 +114,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
{episodes && (
<View className="mt-2">
<View className="mt-4">
<HorizontalScroll<BaseItemDto>
data={episodes}
renderItem={(item, index) => (