This commit is contained in:
Fredrik Burmester
2024-10-08 15:39:44 +02:00
parent a5b4f6cc78
commit ec0843d737
33 changed files with 895 additions and 527 deletions

View File

@@ -1,15 +1,8 @@
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
@@ -23,8 +16,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const [settings] = useSettings();
const audioStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
@@ -35,24 +26,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
[audioStreams, selected]
);
useEffect(() => {
if (selected) return;
const defaultAudioIndex = audioStreams?.find(
(x) => x.Language === settings?.defaultAudioLanguage
)?.Index;
if (defaultAudioIndex !== undefined && defaultAudioIndex !== null) {
onChange(defaultAudioIndex);
return;
}
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
return;
}
onChange(0);
}, [audioStreams, settings, source]);
return (
<View
className="flex shrink"

View File

@@ -9,7 +9,7 @@ export type Bitrate = {
height?: number;
};
const BITRATES: Bitrate[] = [
export const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
@@ -39,7 +39,7 @@ const BITRATES: Bitrate[] = [
value: 250000,
height: 480,
},
];
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;

View File

@@ -17,12 +17,12 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
@@ -31,6 +31,7 @@ import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -42,10 +43,11 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
const { startRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
@@ -54,6 +56,20 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
value: undefined,
});
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
// 4. Set states
setSelectedMediaSource(mediaSource);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [item, settings])
);
const userCanDownload = useMemo(() => {
return user?.Policy?.EnableContentDownloading;
}, [user]);

View File

@@ -1,5 +1,9 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import {
Bitrate,
BITRATES,
BitrateSelector,
} from "@/components/BitrateSelector";
import { DownloadItem } from "@/components/DownloadItem";
import { OverviewText } from "@/components/OverviewText";
import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -20,10 +24,16 @@ import {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { useFocusEffect, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useCastDevice } from "react-native-google-cast";
import Animated from "react-native-reanimated";
@@ -32,13 +42,14 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const castDevice = useCastDevice();
const [settings] = useSettings();
const navigation = useNavigation();
const [loadingLogo, setLoadingLogo] = useState(true);
@@ -47,6 +58,22 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useFocusEffect(
useCallback(() => {
if (!settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(item, settings);
setPlaySettings({
item,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
}, [item, settings])
);
const selectedMediaSource = useMemo(() => {
return playSettings?.mediaSource || undefined;
}, [playSettings?.mediaSource]);
@@ -62,7 +89,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return playSettings?.audioIndex;
}, [playSettings?.audioIndex]);
const setSelectedAudioStream = (audioIndex: number | undefined) => {
const setSelectedAudioStream = (audioIndex: number) => {
setPlaySettings((prev) => ({
...prev,
audioIndex,
@@ -73,7 +100,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return playSettings?.subtitleIndex;
}, [playSettings?.subtitleIndex]);
const setSelectedSubtitleStream = (subtitleIndex: number | undefined) => {
const setSelectedSubtitleStream = (subtitleIndex: number) => {
setPlaySettings((prev) => ({
...prev,
subtitleIndex,
@@ -128,15 +155,14 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
),
});
setPlaySettings((prev) => ({
...prev,
audioIndex: undefined,
subtitleIndex: undefined,
mediaSourceId: undefined,
bitrate: undefined,
mediaSource: item.MediaSources?.[0],
item,
}));
// setPlaySettings((prev) => ({
// audioIndex: undefined,
// subtitleIndex: undefined,
// mediaSourceId: undefined,
// bitrate: undefined,
// mediaSource: item.MediaSources?.[0],
// item,
// }));
}, [item]);
useEffect(() => {

View File

@@ -1,37 +0,0 @@
import React, { useEffect, useRef } from "react";
import Video, { VideoRef } from "react-native-video";
type VideoPlayerProps = {
url: string;
};
export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
const videoRef = useRef<VideoRef | null>(null);
const onError = (error: any) => {
console.error("Video Error: ", error);
};
useEffect(() => {
if (videoRef.current) {
videoRef.current.resume();
}
setTimeout(() => {
if (videoRef.current) {
videoRef.current.presentFullscreenPlayer();
}
}, 500);
}, []);
return (
<Video
source={{
uri: url,
isNetwork: false,
}}
ref={videoRef}
onError={onError}
ignoreSilentSwitch="ignore"
/>
);
};

View File

@@ -1,15 +1,9 @@
import { tc } from "@/utils/textTools";
import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
import { useSettings } from "@/utils/atoms/settings";
interface Props extends React.ComponentProps<typeof View> {
source: MediaSourceInfo;
@@ -23,8 +17,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected,
...props
}) => {
const [settings] = useSettings();
const subtitleStreams = useMemo(
() => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
@@ -35,23 +27,6 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected]
);
useEffect(() => {
// const index = source.DefaultAudioStreamIndex;
// if (index !== undefined && index !== null) {
// onChange(index);
// return;
// }
const defaultSubIndex = subtitleStreams?.find(
(x) => x.Language === settings?.defaultSubtitleLanguage?.value
)?.Index;
if (defaultSubIndex !== undefined && defaultSubIndex !== null) {
onChange(defaultSubIndex);
return;
}
onChange(-1);
}, [subtitleStreams, settings]);
if (subtitleStreams.length === 0) return null;
return (

View File

@@ -76,10 +76,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col"
className="flex flex-col w-44 mr-2"
>
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
<View className="w-44 aspect-video rounded-lg overflow-hidden">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
@@ -92,7 +92,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
/>
</View>
) : (
<View className="w-44 aspect-video rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<View className="w-44 aspect-video rounded-lg bg-neutral-900 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}

View File

@@ -1,16 +1,12 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import iosFmp4 from "@/utils/profiles/iosFmp4";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback } from "react";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
@@ -72,32 +68,18 @@ export const SongsListItem: React.FC<Props> = ({
);
};
const play = async (type: "device" | "cast") => {
const play = useCallback(async (type: "device" | "cast") => {
if (!user?.Id || !api || !item.Id) {
console.warn("No user, api or item", user, api, item.Id);
return;
}
const response = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: item?.Id,
userId: user?.Id,
});
const sessionData = response.data;
const data = await getStreamUrl({
api,
userId: user.Id,
const data = await setPlaySettings({
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : iosFmp4,
mediaSourceId: item.Id,
});
if (!data?.url || !item) {
console.warn("No url or item", data?.url, item.Id);
return;
if (!data?.url) {
throw new Error("play-music ~ No stream url");
}
if (type === "cast" && client) {
@@ -121,12 +103,9 @@ export const SongsListItem: React.FC<Props> = ({
});
} else {
console.log("Playing on device", data.url, item.Id);
setPlaySettings({
item,
});
router.push("/play-music");
}
};
}, []);
return (
<TouchableOpacity

View File

@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps<typeof Button> {
type?: "next" | "previous";
}
export const NextEpisodeButton: React.FC<Props> = ({
export const NextItemButton: React.FC<Props> = ({
item,
type = "next",
...props
@@ -23,8 +23,8 @@ export const NextEpisodeButton: React.FC<Props> = ({
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
const { data: nextEpisode } = useQuery({
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
const { data: nextItem } = useQuery({
queryKey: ["nextItem", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
@@ -47,16 +47,16 @@ export const NextEpisodeButton: React.FC<Props> = ({
});
const disabled = useMemo(() => {
if (!nextEpisode) return true;
if (nextEpisode.Id === item.Id) return true;
if (!nextItem) return true;
if (nextItem.Id === item.Id) return true;
return false;
}, [nextEpisode, type]);
}, [nextItem, type]);
if (item.Type !== "Episode") return null;
return (
<Button
onPress={() => router.setParams({ id: nextEpisode?.Id })}
onPress={() => router.setParams({ id: nextItem?.Id })}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}

View File

@@ -1,24 +1,17 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Dimensions,
Platform,
@@ -38,23 +31,22 @@ import Animated, {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { itemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { TAB_HEIGHT } from "@/constants/Values";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
togglePlay: (ticks: number) => void;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
setShowControls: (shown: boolean) => void;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void;
}
export const Controls: React.FC<Props> = ({
@@ -70,13 +62,13 @@ export const Controls: React.FC<Props> = ({
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
enableTrickplay = true,
}) => {
const [settings] = useSettings();
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { setPlaySettings } = usePlaySettings();
const screenDimensions = Dimensions.get("screen");
const windowDimensions = Dimensions.get("window");
const op = useSharedValue<number>(1);
@@ -117,9 +109,11 @@ export const Controls: React.FC<Props> = ({
}
}, [showControls, isBuffering]);
const { previousItem, nextItem } = useAdjacentEpisodes({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(item);
const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item,
enableTrickplay
);
const [currentTime, setCurrentTime] = useState(0); // Seconds
const [remainingTime, setRemainingTime] = useState(0); // Seconds
@@ -127,7 +121,7 @@ export const Controls: React.FC<Props> = ({
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const from = useMemo(() => segments[2], [segments]);
const wasPlayingRef = useRef(false);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
@@ -152,6 +146,40 @@ export const Controls: React.FC<Props> = ({
videoRef
);
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(previousItem, settings);
setPlaySettings({
item: previousItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
router.replace("/play-video");
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(nextItem, settings);
setPlaySettings({
item: nextItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
router.replace("/play-video");
}, [nextItem, settings]);
useAnimatedReaction(
() => ({
progress: progress.value,
@@ -175,14 +203,12 @@ export const Controls: React.FC<Props> = ({
const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = (value: number) => {
const handleSliderComplete = useCallback((value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
setTimeout(() => {
videoRef.current?.resume();
}, 200);
};
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, []);
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
@@ -190,52 +216,44 @@ export const Controls: React.FC<Props> = ({
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
videoRef.current?.pause();
isSeeking.value = true;
}, [showControls]);
}, [showControls, isPlaying]);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
console.log("handleSkipBackward");
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
setTimeout(() => {
videoRef.current?.resume();
}, 200);
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
console.log("handleSkipForward");
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
setTimeout(() => {
videoRef.current?.resume();
}, 200);
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
// @ts-ignore
router.push(url);
}, [previousItem, from, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
// @ts-ignore
router.push(url);
}, [nextItem, from, router]);
}, [settings, isPlaying]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
@@ -395,7 +413,7 @@ export const Controls: React.FC<Props> = ({
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
@@ -427,7 +445,7 @@ export const Controls: React.FC<Props> = ({
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>