mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-21 08:14:42 +01:00
refactor: Feature/offline mode rework (#859)
Co-authored-by: lostb1t <coding-mosses0z@icloud.com> Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com> Co-authored-by: Gauvino <uruknarb20@gmail.com> Co-authored-by: storm1er <le.storm1er@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Chris <182387676+whoopsi-daisy@users.noreply.github.com> Co-authored-by: arch-fan <55891793+arch-fan@users.noreply.github.com> Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
@@ -35,10 +35,10 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import type { TrackInfo, VlcPlayerViewRef } from "@/modules/VlcPlayer.types";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -82,8 +82,8 @@ interface Props {
|
||||
isVideoLoaded?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture: () => Promise<void>;
|
||||
play: (() => Promise<void>) | (() => void);
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
getAudioTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
getSubtitleTracks?: (() => Promise<TrackInfo[] | null>) | (() => TrackInfo[]);
|
||||
@@ -119,7 +119,6 @@ export const Controls: FC<Props> = ({
|
||||
setSubtitleTrack,
|
||||
setAudioTrack,
|
||||
offline = false,
|
||||
enableTrickplay = true,
|
||||
isVlc = false,
|
||||
}) => {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
@@ -134,13 +133,17 @@ export const Controls: FC<Props> = ({
|
||||
const [showAudioSlider, setShowAudioSlider] = useState(false);
|
||||
|
||||
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
||||
const { previousItem, nextItem } = useAdjacentItems({ item });
|
||||
const { previousItem, nextItem } = usePlaybackManager({
|
||||
item,
|
||||
isOffline: offline,
|
||||
});
|
||||
|
||||
const {
|
||||
trickPlayUrl,
|
||||
calculateTrickplayUrl,
|
||||
trickplayInfo,
|
||||
prefetchAllTrickplayImages,
|
||||
} = useTrickplay(item, !offline && enableTrickplay);
|
||||
} = useTrickplay(item);
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [remainingTime, setRemainingTime] = useState(Number.POSITIVE_INFINITY);
|
||||
@@ -303,19 +306,21 @@ export const Controls: FC<Props> = ({
|
||||
}>();
|
||||
|
||||
const { showSkipButton, skipIntro } = useIntroSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
);
|
||||
|
||||
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
|
||||
offline ? undefined : item.Id,
|
||||
item?.Id!,
|
||||
currentTime,
|
||||
seek,
|
||||
play,
|
||||
isVlc,
|
||||
offline,
|
||||
);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
@@ -323,14 +328,12 @@ export const Controls: FC<Props> = ({
|
||||
if (!item || !settings) {
|
||||
return;
|
||||
}
|
||||
|
||||
lightHapticFeedback();
|
||||
|
||||
const previousIndexes = {
|
||||
subtitleIndex: subtitleIndex
|
||||
? Number.parseInt(subtitleIndex)
|
||||
? Number.parseInt(subtitleIndex, 10)
|
||||
: undefined,
|
||||
audioIndex: audioIndex ? Number.parseInt(audioIndex) : undefined,
|
||||
audioIndex: audioIndex ? Number.parseInt(audioIndex, 10) : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -343,15 +346,18 @@ export const Controls: FC<Props> = ({
|
||||
previousIndexes,
|
||||
mediaSource ?? undefined,
|
||||
);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id ?? "",
|
||||
audioIndex: defaultAudioIndex?.toString() ?? "",
|
||||
subtitleIndex: defaultSubtitleIndex?.toString() ?? "",
|
||||
mediaSourceId: newMediaSource?.Id ?? "",
|
||||
bitrateValue: bitrateValue?.toString(),
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
|
||||
console.log("queryParams", queryParams);
|
||||
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
},
|
||||
@@ -434,10 +440,18 @@ export const Controls: FC<Props> = ({
|
||||
|
||||
const goToItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) {
|
||||
if (offline) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: itemId,
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
|
||||
}).toString();
|
||||
// @ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
return;
|
||||
}
|
||||
const gotoItem = await getItemById(api, itemId);
|
||||
if (!gotoItem) return;
|
||||
goToItemCommon(gotoItem);
|
||||
},
|
||||
[goToItemCommon, api],
|
||||
@@ -726,8 +740,8 @@ export const Controls: FC<Props> = ({
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className={"flex flex-row w-full pt-2"}
|
||||
>
|
||||
{!Platform.isTV && (
|
||||
<View className='mr-auto'>
|
||||
<View className='mr-auto'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<VideoProvider
|
||||
getAudioTracks={getAudioTracks}
|
||||
getSubtitleTracks={getSubtitleTracks}
|
||||
@@ -737,12 +751,13 @@ export const Controls: FC<Props> = ({
|
||||
>
|
||||
<DropdownView />
|
||||
</VideoProvider>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='flex flex-row items-center space-x-2 '>
|
||||
{!Platform.isTV &&
|
||||
settings.defaultPlayer === VideoPlayer.VLC_4 && (
|
||||
(settings.defaultPlayer === VideoPlayer.VLC_4 ||
|
||||
Platform.OS === "android") && (
|
||||
<TouchableOpacity
|
||||
onPress={startPictureInPicture}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
@@ -755,8 +770,7 @@ export const Controls: FC<Props> = ({
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{item?.Type === "Episode" && !offline && (
|
||||
{item?.Type === "Episode" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
switchOnEpisodeMode();
|
||||
@@ -766,7 +780,7 @@ export const Controls: FC<Props> = ({
|
||||
<Ionicons name='list' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{previousItem && !offline && (
|
||||
{previousItem && (
|
||||
<TouchableOpacity
|
||||
onPress={goToPreviousItem}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
@@ -774,8 +788,7 @@ export const Controls: FC<Props> = ({
|
||||
<Ionicons name='play-skip-back' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{nextItem && !offline && (
|
||||
{nextItem && (
|
||||
<TouchableOpacity
|
||||
onPress={() => goToNextItem({ isAutoPlay: false })}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
@@ -783,7 +796,6 @@ export const Controls: FC<Props> = ({
|
||||
<Ionicons name='play-skip-forward' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* {mediaSource?.TranscodingUrl && ( */}
|
||||
<TouchableOpacity
|
||||
onPress={toggleIgnoreSafeAreas}
|
||||
@@ -795,7 +807,6 @@ export const Controls: FC<Props> = ({
|
||||
color='white'
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{/* )} */}
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2'
|
||||
@@ -804,7 +815,6 @@ export const Controls: FC<Props> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
||||
@@ -2,22 +2,24 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
type HorizontalScrollRef,
|
||||
} from "@/components/common/HorrizontalScroll";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import {
|
||||
SeasonDropdown,
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
@@ -33,69 +35,94 @@ export const seasonIndexAtom = atom<SeasonIndexState>({});
|
||||
export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const _insets = useSafeAreaInsets(); // Get safe area insets
|
||||
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
|
||||
const scrollViewRef = useRef<HorizontalScrollRef>(null); // Reference to the HorizontalScroll
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const { offline } = useGlobalSearchParams<{
|
||||
offline: string;
|
||||
}>();
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
if (item.SeriesId) {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.SeriesId ?? ""]: item.ParentIndexNumber ?? 0,
|
||||
[item.ParentId ?? ""]: item.ParentIndexNumber ?? 0,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.SeriesId ?? ""];
|
||||
const [seriesItem, setSeriesItem] = useState<BaseItemDto | null>(null);
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = getDownloadedItems();
|
||||
|
||||
// This effect fetches the series item data/
|
||||
useEffect(() => {
|
||||
if (item.SeriesId) {
|
||||
getUserItemData({ api, userId: user?.Id, itemId: item.SeriesId }).then(
|
||||
(res) => {
|
||||
setSeriesItem(res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [item.SeriesId]);
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.SeriesId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
const seriesEpisodes = downloadedFiles?.filter(
|
||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||
);
|
||||
const seasonNumbers = [
|
||||
...new Set(
|
||||
seriesEpisodes
|
||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
// Create fake season objects
|
||||
return seasonNumbers.map((seasonNumber) => ({
|
||||
Id: seasonNumber?.toString(),
|
||||
IndexNumber: seasonNumber,
|
||||
Name: `Season ${seasonNumber}`,
|
||||
SeriesId: item.SeriesId,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!api || !user?.Id || !item.SeriesId) return [];
|
||||
const response = await api.axiosInstance.get(
|
||||
`${api.basePath}/Shows/${item.SeriesId}/Seasons`,
|
||||
{
|
||||
params: {
|
||||
userId: user?.Id,
|
||||
itemId: item.SeriesId,
|
||||
Fields:
|
||||
"ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount",
|
||||
},
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const response = await getTvShowsApi(api).getSeasons({
|
||||
seriesId: item.SeriesId,
|
||||
userId: user.Id,
|
||||
fields: [
|
||||
"ItemCounts",
|
||||
"PrimaryImageAspectRatio",
|
||||
"CanDelete",
|
||||
"MediaSourceCount",
|
||||
],
|
||||
});
|
||||
return response.data.Items;
|
||||
},
|
||||
enabled: !!api && !!user?.Id && !!item.SeasonId,
|
||||
enabled: isOffline
|
||||
? !!item.SeriesId
|
||||
: !!api && !!user?.Id && !!item.SeasonId,
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
seasons
|
||||
?.find((season: any) => season.IndexNumber === seasonIndex)
|
||||
?.Id?.toString() || null,
|
||||
[seasons, seasonIndex],
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
const { data: episodes, isLoading: episodesLoading } = useQuery({
|
||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f: DownloadedItem) =>
|
||||
f.item.SeriesId === item.SeriesId &&
|
||||
f.item.ParentIndexNumber === seasonIndex,
|
||||
)
|
||||
.map((f: DownloadedItem) => f.item);
|
||||
}
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
seriesId: item.SeriesId || "",
|
||||
@@ -112,7 +139,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (item?.Type === "Episode" && item.Id) {
|
||||
const index = episodes?.findIndex((ep) => ep.Id === item.Id);
|
||||
const index = episodes?.findIndex((ep: BaseItemDto) => ep.Id === item.Id);
|
||||
if (index !== undefined && index !== -1) {
|
||||
setTimeout(() => {
|
||||
scrollToIndex(index);
|
||||
@@ -150,12 +177,8 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}
|
||||
}, [episodes, item.Id]);
|
||||
|
||||
if (!episodes) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
<SafeAreaView
|
||||
style={{
|
||||
position: "absolute",
|
||||
backgroundColor: "black",
|
||||
@@ -163,21 +186,16 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
className={"flex flex-row items-center space-x-2 z-10 p-4"}
|
||||
>
|
||||
{seriesItem && (
|
||||
<View className='flex-row items-center p-4 z-10'>
|
||||
{seasons && seasons.length > 0 && !episodesLoading && episodes && (
|
||||
<SeasonDropdown
|
||||
item={seriesItem}
|
||||
item={item}
|
||||
seasons={seasons}
|
||||
state={seasonIndexState}
|
||||
onSelect={(season) => {
|
||||
setSeasonIndexState((prev) => ({
|
||||
...prev,
|
||||
[item.SeriesId ?? ""]: season.IndexNumber,
|
||||
[item.ParentId ?? ""]: season.IndexNumber,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -186,64 +204,72 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
onPress={async () => {
|
||||
close();
|
||||
}}
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2'
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<HorizontalScroll
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, _idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${
|
||||
item.Id !== _item.Id ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
}}
|
||||
{!episodes || episodesLoading ? (
|
||||
<View
|
||||
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
|
||||
>
|
||||
<Loader />
|
||||
</View>
|
||||
) : (
|
||||
<HorizontalScroll
|
||||
ref={scrollViewRef}
|
||||
data={episodes}
|
||||
extraData={item}
|
||||
renderItem={(_item, _idx) => (
|
||||
<View
|
||||
key={_item.Id}
|
||||
style={{}}
|
||||
className={`flex flex-col w-44 ${
|
||||
item.Id !== _item.Id ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
lineHeight: 18, // Adjust this value based on your text size
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
goToItem(_item.Id);
|
||||
}}
|
||||
>
|
||||
{_item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
<ContinueWatchingPoster
|
||||
item={_item}
|
||||
useEpisodePoster
|
||||
showPlayButton={_item.Id !== item.Id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View className='shrink'>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={{
|
||||
lineHeight: 18, // Adjust this value based on your text size
|
||||
height: 36, // lineHeight * 2 for consistent two-line space
|
||||
}}
|
||||
>
|
||||
{_item.Name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} className='text-xs text-neutral-475'>
|
||||
{`S${_item.ParentIndexNumber?.toString()}:E${_item.IndexNumber?.toString()}`}
|
||||
</Text>
|
||||
<Text className='text-xs text-neutral-500'>
|
||||
{runtimeTicksToSeconds(_item.RunTimeTicks)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={5}
|
||||
className='text-xs text-neutral-500 shrink'
|
||||
>
|
||||
{_item.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='self-start mt-2'>
|
||||
<DownloadSingleItem item={_item} />
|
||||
</View>
|
||||
<Text numberOfLines={5} className='text-xs text-neutral-500 shrink'>
|
||||
{_item.Overview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={(e: BaseItemDto) => e.Id ?? ""}
|
||||
estimatedItemSize={200}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TrackInfo } from "@/modules/VlcPlayer.types";
|
||||
import { useSettings, VideoPlayer } from "@/utils/atoms/settings";
|
||||
import type { Track } from "../types";
|
||||
import { useControlContext } from "./ControlContext";
|
||||
|
||||
@@ -48,7 +48,6 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
}) => {
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [settings] = useSettings();
|
||||
|
||||
const ControlContext = useControlContext();
|
||||
const isVideoLoaded = ControlContext?.isVideoLoaded;
|
||||
@@ -67,13 +66,17 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
playbackPosition: string;
|
||||
}>();
|
||||
|
||||
const onTextBasedSubtitle = useMemo(
|
||||
() =>
|
||||
const onTextBasedSubtitle = useMemo(() => {
|
||||
return (
|
||||
allSubs.find(
|
||||
(s) => s.Index?.toString() === subtitleIndex && s.IsTextSubtitleStream,
|
||||
) || subtitleIndex === "-1",
|
||||
[allSubs, subtitleIndex],
|
||||
);
|
||||
(s) =>
|
||||
s.Index?.toString() === subtitleIndex &&
|
||||
(s.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
s.DeliveryMethod === SubtitleDeliveryMethod.External),
|
||||
) || subtitleIndex === "-1"
|
||||
);
|
||||
}, [allSubs, subtitleIndex]);
|
||||
|
||||
const setPlayerParams = ({
|
||||
chosenAudioIndex = audioIndex,
|
||||
@@ -92,7 +95,7 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
playbackPosition: playbackPosition,
|
||||
}).toString();
|
||||
|
||||
//@ts-ignore
|
||||
//@ts-expect-error
|
||||
router.replace(`player/direct-player?${queryParams}`);
|
||||
};
|
||||
|
||||
@@ -128,30 +131,32 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
||||
useEffect(() => {
|
||||
const fetchTracks = async () => {
|
||||
if (getSubtitleTracks) {
|
||||
const subtitleData = await getSubtitleTracks();
|
||||
let subtitleData = await getSubtitleTracks();
|
||||
// Only FOR VLC 3, If we're transcoding, we need to reverse the subtitle data, because VLC reverses the HLS subtitles.
|
||||
if (
|
||||
mediaSource?.TranscodingUrl &&
|
||||
subtitleData &&
|
||||
subtitleData.length > 1
|
||||
) {
|
||||
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
|
||||
}
|
||||
|
||||
// Step 1: Move external subs to the end, because VLC puts external subs at the end
|
||||
const sortedSubs = allSubs.sort(
|
||||
(a, b) => Number(a.IsExternal) - Number(b.IsExternal),
|
||||
);
|
||||
|
||||
// Step 2: Apply VLC indexing logic
|
||||
let textSubIndex = settings.defaultPlayer === VideoPlayer.VLC_4 ? 0 : 1;
|
||||
const processedSubs: Track[] = sortedSubs?.map((sub) => {
|
||||
// Always increment for non-transcoding subtitles
|
||||
// Only increment for text-based subtitles when transcoding
|
||||
let embedSubIndex = 1;
|
||||
const processedSubs: Track[] = allSubs?.map((sub) => {
|
||||
/** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
|
||||
const shouldIncrement =
|
||||
!mediaSource?.TranscodingUrl || sub.IsTextSubtitleStream;
|
||||
const vlcIndex = subtitleData?.at(textSubIndex)?.index ?? -1;
|
||||
const finalIndex = shouldIncrement ? vlcIndex : (sub.Index ?? -1);
|
||||
|
||||
if (shouldIncrement) textSubIndex++;
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
|
||||
sub.DeliveryMethod === SubtitleDeliveryMethod.External;
|
||||
/** The index of subtitle inside VLC Player Itself */
|
||||
const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
|
||||
if (shouldIncrement) embedSubIndex++;
|
||||
return {
|
||||
name: sub.DisplayTitle || "Undefined Subtitle",
|
||||
index: sub.Index ?? -1,
|
||||
setTrack: () =>
|
||||
shouldIncrement
|
||||
? setTrackParams("subtitle", finalIndex, sub.Index ?? -1)
|
||||
? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
|
||||
: setPlayerParams({
|
||||
chosenSubtitleIndex: sub.Index?.toString(),
|
||||
}),
|
||||
|
||||
@@ -19,7 +19,7 @@ const DropdownView = () => {
|
||||
];
|
||||
const router = useRouter();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -27,8 +27,11 @@ const DropdownView = () => {
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const changeBitrate = useCallback(
|
||||
(bitrate: string) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
@@ -61,32 +64,34 @@ const DropdownView = () => {
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='qualitytrigger'>
|
||||
Quality
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`quality-item-${idx}`}
|
||||
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||
onValueChange={() =>
|
||||
changeBitrate(bitrate.value?.toString() ?? "")
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{bitrate.key}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{!isOffline && (
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='qualitytrigger'>
|
||||
Quality
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent
|
||||
alignOffset={-10}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={0}
|
||||
loop={true}
|
||||
sideOffset={10}
|
||||
>
|
||||
{BITRATES?.map((bitrate, idx: number) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
key={`quality-item-${idx}`}
|
||||
value={bitrateValue === (bitrate.value?.toString() ?? "")}
|
||||
onValueChange={() =>
|
||||
changeBitrate(bitrate.value?.toString() ?? "")
|
||||
}
|
||||
>
|
||||
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||
{bitrate.key}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
))}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
)}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger key='subtitle-trigger'>
|
||||
Subtitle
|
||||
|
||||
Reference in New Issue
Block a user