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:
Alex
2025-08-16 05:34:22 +10:00
committed by GitHub
parent 4fba558c33
commit ca92f61900
94 changed files with 3325 additions and 3523 deletions

View File

@@ -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",

View File

@@ -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>
);
};

View File

@@ -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(),
}),

View File

@@ -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