wip: design refactor

This commit is contained in:
Fredrik Burmester
2024-08-28 22:00:50 +02:00
parent 309345c834
commit 4641ff726c
16 changed files with 580 additions and 334 deletions

View File

@@ -61,7 +61,7 @@ const page: React.FC = () => {
return (
<ParallaxScrollView
headerHeight={300}
headerHeight={400}
headerImage={
<Image
source={{

View File

@@ -38,18 +38,21 @@ export const AudioTrackSelector: React.FC<Props> = ({
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<View
className="flex shrink"
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Audio</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 7)}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="" numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content

View File

@@ -1,6 +1,7 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { useMemo } from "react";
export type Bitrate = {
key: string;
@@ -43,41 +44,57 @@ const BITRATES: Bitrate[] = [
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
inverted?: boolean;
}
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
inverted,
...props
}) => {
const sorted = useMemo(() => {
if (inverted)
return BITRATES.sort(
(a, b) => (a.value || Infinity) - (b.value || Infinity)
);
return BITRATES.sort(
(a, b) => (b.value || Infinity) - (a.value || Infinity)
);
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<View
className="flex shrink"
style={{
minWidth: 60,
maxWidth: 200,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Quality</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
loop={false}
side="bottom"
align="start"
align="center"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b, index: number) => (
{sorted.map((b) => (
<DropdownMenu.Item
key={index.toString()}
key={b.key}
onSelect={() => {
onChange(b);
}}

View File

@@ -39,7 +39,7 @@ export const Chromecast: React.FC<Props> = ({
if (background === "transparent")
return (
<View
className=" rounded h-10 aspect-square flex items-center justify-center"
className="rounded-full h-10 w-10 flex items-center justify-center bg-neutral-800/80"
{...props}
>
<CastButton style={{ tintColor: "white", height, width }} />

View File

@@ -2,8 +2,17 @@ import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
import { useSettings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
BaseItemDto,
MediaSourceInfo,
@@ -12,19 +21,16 @@ 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,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { useCallback, useMemo, useRef, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
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";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -35,100 +41,134 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { startRemuxing } = useRemuxHlsToMp4(item);
const initiateDownload = useCallback(
async (qualitySetting: DownloadQuality) => {
if (!api || !user?.Id || !item.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
let deviceProfile: any = ios;
/**
* Bottom sheet
*/
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["50%"], []);
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present();
}, []);
let maxStreamingBitrate: number | undefined = undefined;
const handleSheetChanges = useCallback((index: number) => {
console.log("handleSheetChanges", index);
}, []);
if (qualitySetting === "high") {
maxStreamingBitrate = 8000000;
} else if (qualitySetting === "low") {
maxStreamingBitrate = 2000000;
}
const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
}, []);
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}"`,
},
}
/**
* Start download
*/
const initiateDownload = useCallback(async () => {
if (!api || !user?.Id || !item.Id || !selectedMediaSource?.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let url: string | undefined = undefined;
let deviceProfile: any = ios;
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
if (!mediaSource) {
throw new Error("No media source");
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
UserId: user.Id,
MaxStreamingBitrate: maxBitrate.value,
StartTimeTicks: 0,
EnableTranscoding: maxBitrate.value ? true : undefined,
AutoOpenLiveStream: true,
AllowVideoStreamCopy: maxBitrate.value ? false : true,
MediaSourceId: selectedMediaSource?.Id,
AudioStreamIndex: selectedAudioStream,
SubtitleStreamIndex: selectedSubtitleStream,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
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()}`;
}
let url: string | undefined = undefined;
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
);
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&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} 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");
}
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]
);
return await startRemuxing(url);
}, [
api,
item,
startRemuxing,
user?.Id,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
maxBitrate,
]);
/**
* Check if item is downloaded
*/
const { data: downloaded, isFetching } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
@@ -143,6 +183,17 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
enabled: !!item.Id,
});
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
@@ -187,23 +238,72 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
if (!settings?.downloadQuality?.value) {
throw new Error("No download quality selected");
}
await initiateDownload(settings?.downloadQuality?.value);
},
item,
});
}}
>
<TouchableOpacity onPress={handlePresentModalPress}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
)}
<BottomSheetModal
ref={bottomSheetModalRef}
enableDynamicSizing
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<Text className="font-bold text-2xl text-neutral-10">
Download options
</Text>
<View className="flex flex-col space-y-2 w-full items-start">
<BitrateSelector
inverted
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<MediaSourceSelector
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-col space-y-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
)}
</View>
<Button
className="mt-auto"
onPress={() => {
closeModal();
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await initiateDownload();
},
item,
});
}}
color="purple"
>
Download
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View>
);
};

View File

@@ -32,16 +32,23 @@ import { useCastDevice } from "react-native-google-cast";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { useImageColors } from "@/hooks/useImageColors";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Loader } from "./Loader";
import { set } from "lodash";
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
const opacity = useSharedValue(0);
const castDevice = useCastDevice();
const navigation = useNavigation();
const [settings] = useSettings();
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
@@ -52,6 +59,27 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
value: undefined,
});
const [loadingImage, setLoadingImage] = useState(true);
const [loadingLogo, setLoadingLogo] = useState(true);
const animatedStyle = useAnimatedStyle(() => {
return {
opacity: opacity.value,
};
});
const fadeIn = () => {
opacity.value = withTiming(1, { duration: 300 });
};
const fadeOut = (callback: any) => {
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(callback)();
}
});
};
const headerHeightRef = useRef(0);
const {
@@ -70,9 +98,32 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
return res;
},
enabled: !!id && !!api,
staleTime: 60 * 1000,
staleTime: 60 * 1000 * 5,
});
const [localItem, setLocalItem] = useState(item);
useEffect(() => {
if (item) {
if (localItem) {
// Fade out current item
fadeOut(() => {
// Update local item after fade out
setLocalItem(item);
// Then fade in
fadeIn();
});
} else {
// If there's no current item, just set and fade in
setLocalItem(item);
fadeIn();
}
} else {
// If item is null, fade out and clear local item
fadeOut(() => setLocalItem(null));
}
}, [item]);
useEffect(() => {
navigation.setOptions({
headerRight: () =>
@@ -88,7 +139,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
useEffect(() => {
if (item?.Type === "Episode") headerHeightRef.current = 400;
else headerHeightRef.current = 500;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
}, [item]);
const { data: sessionData } = useQuery({
@@ -155,110 +206,123 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(
() => isLoading || isFetching,
[isLoading, isFetching]
);
const loading = useMemo(() => {
return Boolean(
isLoading || isFetching || loadingImage || (logoUrl && loadingLogo)
);
}, [isLoading, isFetching, loadingImage, loadingLogo, logoUrl]);
return (
<ParallaxScrollView
headerHeight={headerHeightRef.current}
headerImage={
<>
{item ? (
<ItemImage
variant={
item.Type === "Movie" && logoUrl ? "Backdrop" : "Primary"
}
item={item}
style={{
width: "100%",
height: "100%",
}}
/>
) : (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: "black",
}}
></View>
)}
</>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col bg-transparent">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2">
<ItemHeader item={item} className="mb-4" />
{item ? (
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
<View className="flex-1 relative">
{loading && (
<View className="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex flex-col justify-center items-center z-50">
<Loader />
</View>
)}
<ParallaxScrollView
className={`flex-1 ${loading ? "opacity-0" : "opacity-100"}`}
headerHeight={headerHeightRef.current}
headerImage={
<>
<Animated.View style={[animatedStyle, { flex: 1 }]}>
{localItem && (
<ItemImage
variant={
localItem.Type === "Movie" && logoUrl
? "Backdrop"
: "Primary"
}
item={localItem}
style={{
width: "100%",
height: "100%",
}}
onLoad={() => setLoadingImage(false)}
onError={() => setLoadingImage(false)}
/>
)}
</Animated.View>
</>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)}
/>
<MediaSourceSelector
item={item}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<View className="flex flex-row items-center space-x-2">
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
) : null}
</>
}
>
<View className="flex flex-col bg-transparent shrink">
<View className="flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink">
<Animated.View style={[animatedStyle, { flex: 1 }]}>
<ItemHeader item={localItem} className="mb-4" />
{localItem ? (
<View className="flex flex-row items-center justify-start w-full h-16">
<BitrateSelector
className="mr-1"
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
<MediaSourceSelector
className="mr-1"
item={localItem}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
/>
{selectedMediaSource && (
<>
<AudioTrackSelector
className="mr-1"
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</>
)}
</View>
) : (
<View className="h-16">
<View className="bg-neutral-900 h-4 w-2/4 rounded-md mb-1"></View>
<View className="bg-neutral-900 h-10 w-3/4 rounded-lg"></View>
</View>
)}
</View>
) : (
<View className="mb-1">
<View className="bg-neutral-950 h-4 w-2/4 rounded-md mb-1"></View>
<View className="bg-neutral-950 h-10 w-3/4 rounded-lg"></View>
</View>
</Animated.View>
<PlayButton item={item} url={playbackUrl} className="grow" />
</View>
{item?.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<PlayButton item={item} url={playbackUrl} className="grow" />
<OverviewText text={item?.Overview} className="px-4 mb-4" />
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item?.Id} />
<View className="h-16"></View>
</View>
{item?.Type === "Episode" && (
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item?.Overview} className="px-4 mb-4" />
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}
<SimilarItems itemId={item?.Id} />
<View className="h-16"></View>
</View>
</ParallaxScrollView>
</ParallaxScrollView>
</View>
);
});

View File

@@ -11,17 +11,25 @@ interface Props extends ViewProps {
export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View className="w-full items-center">
<View className="w-1/3 h-4 mb-1 bg-neutral-950 rounded" />
<View className="w-2/3 h-8 mb-1 bg-neutral-950 rounded" />
<View className="w-2/3 h-3 mb-1 bg-neutral-950 rounded" />
<View className="w-1/4 h-3 mb-1 bg-neutral-950 rounded" />
<View className="w-1/4 h-6 bg-neutral-950 rounded" />
<View
className="flex flex-col space-y-1.5 w-full items-start h-24"
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
<View className="w-2/3 h-8 bg-neutral-900 rounded" />
<View className="w-2/3 h-4 bg-neutral-900 rounded" />
<View className="w-1/4 h-4 bg-neutral-900 rounded" />
</View>
);
return (
<View className="flex flex-col" {...props}>
<View
style={{
minHeight: 96,
}}
className="flex flex-col"
{...props}
>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}

View File

@@ -37,16 +37,19 @@ export const MediaSourceSelector: React.FC<Props> = ({
}, [mediaSources]);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<View
className="flex shrink"
style={{
minWidth: 50,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<View className="flex flex-col" {...props}>
<Text className="opacity-50 mb-1 text-xs">Video</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">{tc(selectedMediaSource, 7)}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center ">
<Text numberOfLines={1}>{selectedMediaSource}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -66,12 +69,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
onChange(source);
}}
>
<DropdownMenu.ItemTitle>
{
source.MediaStreams?.find((s) => s.Type === "Video")
?.DisplayTitle
}
</DropdownMenu.ItemTitle>
<DropdownMenu.ItemTitle>{source.Name}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>

View File

@@ -1,6 +1,6 @@
import { LinearGradient } from "expo-linear-gradient";
import { type PropsWithChildren, type ReactElement } from "react";
import { View } from "react-native";
import { View, ViewProps } from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
@@ -8,19 +8,20 @@ import Animated, {
useScrollViewOffset,
} from "react-native-reanimated";
type Props = PropsWithChildren<{
interface Props extends ViewProps {
headerImage: ReactElement;
logo?: ReactElement;
episodePoster?: ReactElement;
headerHeight?: number;
}>;
}
export const ParallaxScrollView: React.FC<Props> = ({
export const ParallaxScrollView: React.FC<PropsWithChildren<Props>> = ({
children,
headerImage,
episodePoster,
headerHeight = 400,
logo,
...props
}: Props) => {
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
@@ -47,7 +48,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
});
return (
<View className="flex-1">
<View className="flex-1" {...props}>
<Animated.ScrollView
style={{
position: "relative",

View File

@@ -3,7 +3,7 @@ import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import CastContext, {
PlayServicesState,
@@ -13,6 +13,14 @@ import { Button } from "./Button";
import { Text } from "./common/Text";
import { useAtom } from "jotai";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
runOnJS,
useAnimatedReaction,
} from "react-native-reanimated";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
@@ -26,6 +34,47 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const [color] = useAtom(itemThemeColorAtom);
// Create a shared value for animation progress
const progress = useSharedValue(0);
// Create shared values for start and end colors
const startColor = useSharedValue(color);
const endColor = useSharedValue(color);
useEffect(() => {
// When color changes, update end color and animate progress
endColor.value = color;
progress.value = 0; // Reset progress
progress.value = withTiming(1, { duration: 300 }); // Animate to 1 over 500ms
}, [color]);
// Animated style for primary color
const animatedPrimaryStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
progress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
),
}));
// Animated style for text color
const animatedTextStyle = useAnimatedStyle(() => ({
color: interpolateColor(
progress.value,
[0, 1],
[startColor.value.text, endColor.value.text]
),
}));
// Update start color after animation completes
useEffect(() => {
const timeout = setTimeout(() => {
startColor.value = color;
}, 500); // Should match the duration in withTiming
return () => clearTimeout(timeout);
}, [color]);
const onPress = async () => {
if (!url || !item) return;
@@ -85,37 +134,43 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
return (
<TouchableOpacity onPress={onPress} className="relative" {...props}>
<Animated.View
style={[
animatedPrimaryStyle,
{
width:
playbackPercent === 0
? "100%"
: `${Math.max(playbackPercent, 15)}%`,
height: "100%",
},
]}
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
/>
<Animated.View
style={[animatedPrimaryStyle]}
className="absolute w-full h-full top-0 left-0 rounded-xl "
/>
<View
style={{
width:
playbackPercent === 0
? "100%"
: `${Math.max(playbackPercent, 15)}%`,
height: "100%",
backgroundColor: color.primary,
borderWidth: 1,
borderColor: color.primary,
borderStyle: "solid",
}}
className="absolute w-full h-full top-0 left-0 rounded-xl z-10"
></View>
<View
style={{
height: "100%",
width: "100%",
backgroundColor: color.primary,
}}
className="absolute w-full h-full top-0 left-0 rounded-xl opacity-40"
></View>
<View className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full ">
className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full "
>
<View className="flex flex-row items-center space-x-2">
<Text
className="font-bold"
style={{
color: color.text,
}}
>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Text>
<Ionicons name="play-circle" size={24} color={color.text} />
{client && <Feather name="cast" size={22} color={color.text} />}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name="play-circle" size={24} />
</Animated.Text>
{client && (
<Animated.Text style={animatedTextStyle}>
<Feather name="cast" size={22} />
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>

View File

@@ -44,20 +44,24 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null;
return (
<View className="flex flex-row items-center justify-between" {...props}>
<View
className="flex col shrink justify-start place-self-start items-start"
style={{
minWidth: 60,
maxWidth: 200,
}}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<View className="flex flex-col " {...props}>
<Text className="opacity-50 mb-1 text-xs">Subtitle</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: "None"}
</Text>
</TouchableOpacity>
</View>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text className=" ">
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 7)
: "None"}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content

View File

@@ -45,7 +45,7 @@ export const HeaderBackButton: React.FC<Props> = ({
return (
<TouchableOpacity
onPress={() => router.back()}
className=" bg-black rounded-full p-2 border border-neutral-900"
className=" bg-neutral-800/80 rounded-full p-2"
{...touchableOpacityProps}
>
<Ionicons

View File

@@ -1,7 +1,7 @@
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 { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
@@ -11,6 +11,10 @@ import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Image } from "expo-image";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
type Props = {
item: BaseItemDto;
@@ -96,27 +100,39 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Episodes`,
{
params: {
userId: user?.Id,
seasonId: selectedSeasonId,
Fields:
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
fields: ["MediaSources", "MediaStreams", "Overview"],
});
return response.data.Items as BaseItemDto[];
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
const queryClient = useQueryClient();
useEffect(() => {
for (let e of episodes || []) {
queryClient.prefetchQuery({
queryKey: ["item", e.Id],
queryFn: async () => {
if (!e.Id) return;
const res = await getUserItemData({
api,
userId: user?.Id,
itemId: e.Id,
});
return res;
},
staleTime: 60 * 5 * 1000,
});
}
}, [episodes]);
// Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => {
@@ -164,26 +180,6 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/* Old View. Might have a setting later to manually select view. */}
{/* {episodes && (
<View className="mt-4">
<HorizontalScroll
data={episodes}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => {
router.push(`/(auth)/items/${item.Id}`);
}}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View>
)} */}
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View

View File

@@ -58,7 +58,7 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View
{/* <View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
@@ -97,7 +97,7 @@ export const SettingToggles: React.FC = () => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
</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>

View File

@@ -62,8 +62,8 @@ export const itemThemeColorAtom = atom(
const newColors = { ...currentColors, ...update };
// Recalculate text color if primary color changes
if (update.primary) {
newColors.text = calculateTextColor(update.primary);
if (update.average) {
newColors.text = calculateTextColor(update.average);
}
set(baseThemeColorAtom, newColors);

View File

@@ -79,7 +79,7 @@ export const getStreamUrl = async ({
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true`;
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({