diff --git a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index 15ae2870..73870886 100644 --- a/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -61,7 +61,7 @@ const page: React.FC = () => { return ( = ({ }, []); return ( - + - + Audio - - - - {tc(selectedAudioSteam?.DisplayTitle, 7)} - - - + + + {selectedAudioSteam?.DisplayTitle} + + { onChange: (value: Bitrate) => void; selected: Bitrate; + inverted?: boolean; } export const BitrateSelector: React.FC = ({ 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 ( - + - + Quality - - - - {BITRATES.find((b) => b.value === selected.value)?.key} - - - + + + {BITRATES.find((b) => b.value === selected.value)?.key} + + Bitrates - {BITRATES?.map((b, index: number) => ( + {sorted.map((b) => ( { onChange(b); }} diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx index f3ff8f16..60c4400d 100644 --- a/components/Chromecast.tsx +++ b/components/Chromecast.tsx @@ -39,7 +39,7 @@ export const Chromecast: React.FC = ({ if (background === "transparent") return ( diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index d2ff0893..a07c0e9a 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -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 = ({ 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(null); + const [selectedAudioStream, setSelectedAudioStream] = useState(-1); + const [selectedSubtitleStream, setSelectedSubtitleStream] = + useState(0); + const [maxBitrate, setMaxBitrate] = useState({ + key: "Max", + value: undefined, + }); - let deviceProfile: any = ios; + /** + * Bottom sheet + */ + const bottomSheetModalRef = useRef(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 = ({ item, ...props }) => { enabled: !!item.Id, }); + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + return ( = ({ item, ...props }) => { ) : ( - { - 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, - }); - }} - > + )} + + + + + Download options + + + setMaxBitrate(val)} + selected={maxBitrate} + /> + + {selectedMediaSource && ( + + + + + )} + + + + + ); }; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index f64e17a2..536d43e6 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -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(null); const [selectedAudioStream, setSelectedAudioStream] = useState(-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 ( - - {item ? ( - - ) : ( - - )} - - } - logo={ - <> - {logoUrl ? ( - - ) : null} - - } - > - - - - - {item ? ( - - setMaxBitrate(val)} - selected={maxBitrate} + + {loading && ( + + + + )} + + + {localItem && ( + setLoadingImage(false)} + onError={() => setLoadingImage(false)} + /> + )} + + + } + logo={ + <> + {logoUrl ? ( + setLoadingLogo(false)} + onError={() => setLoadingLogo(false)} /> - - {selectedMediaSource && ( - - + } + > + + + + + {localItem ? ( + + setMaxBitrate(val)} + selected={maxBitrate} /> - + {selectedMediaSource && ( + <> + + + + )} + + ) : ( + + + )} - - ) : ( - - - - + + + + + + {item?.Type === "Episode" && ( + )} - + + + + + {item?.Type === "Episode" && ( + + )} + + + - - {item?.Type === "Episode" && ( - - )} - - - - - - {item?.Type === "Episode" && ( - - )} - - - - - + + ); }); diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx index 1eb15d64..dcffe023 100644 --- a/components/ItemHeader.tsx +++ b/components/ItemHeader.tsx @@ -11,17 +11,25 @@ interface Props extends ViewProps { export const ItemHeader: React.FC = ({ item, ...props }) => { if (!item) return ( - - - - - - + + + + + ); return ( - + {item.Type === "Episode" && } {item.Type === "Movie" && } diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index 44a3766a..b32ceb4b 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -37,16 +37,19 @@ export const MediaSourceSelector: React.FC = ({ }, [mediaSources]); return ( - + - + Video - - - {tc(selectedMediaSource, 7)} - - + + {selectedMediaSource} + = ({ onChange(source); }} > - - { - source.MediaStreams?.find((s) => s.Type === "Video") - ?.DisplayTitle - } - + {source.Name} ))} diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx index 764ed9ef..daebca6b 100644 --- a/components/ParallaxPage.tsx +++ b/components/ParallaxPage.tsx @@ -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 = ({ +export const ParallaxScrollView: React.FC> = ({ children, headerImage, episodePoster, headerHeight = 400, logo, + ...props }: Props) => { const scrollRef = useAnimatedRef(); const scrollOffset = useScrollViewOffset(scrollRef); @@ -47,7 +48,7 @@ export const ParallaxScrollView: React.FC = ({ }); return ( - + { item?: BaseItemDto | null; @@ -26,6 +34,47 @@ export const PlayButton: React.FC = ({ 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 = ({ item, url, ...props }) => { return ( + + - - + className="flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full " + > - + {runtimeTicksToMinutes(item?.RunTimeTicks)} - - - {client && } + + + + + {client && ( + + + + )} diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index c70ad7dc..3b53a7d9 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -44,20 +44,24 @@ export const SubtitleTrackSelector: React.FC = ({ if (subtitleStreams.length === 0) return null; return ( - + - + Subtitle - - - - {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 7) - : "None"} - - - + + + {selectedSubtitleSteam + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) + : "None"} + + = ({ return ( router.back()} - className=" bg-black rounded-full p-2 border border-neutral-900" + className=" bg-neutral-800/80 rounded-full p-2" {...touchableOpacityProps} > = ({ 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 = ({ item, initialSeasonIndex }) => { ))} - {/* Old View. Might have a setting later to manually select view. */} - {/* {episodes && ( - - ( - { - router.push(`/(auth)/items/${item.Id}`); - }} - className="flex flex-col w-48" - > - - - - )} - /> - - )} */} {isFetching ? ( { onValueChange={(value) => updateSettings({ autoRotate: value })} /> - { ))} - + */} Start videos in fullscreen diff --git a/utils/atoms/primaryColor.ts b/utils/atoms/primaryColor.ts index d7036163..99dbc709 100644 --- a/utils/atoms/primaryColor.ts +++ b/utils/atoms/primaryColor.ts @@ -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); diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 1ca210a7..075d486f 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -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({