mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: move source and track selection to seperate sheet (#1176)
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
@@ -20,7 +21,11 @@ const Page: React.FC = () => {
|
||||
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||
const isOffline = offline === "true";
|
||||
|
||||
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||
const { data: item, isError } = useItemQuery(id, false, undefined, [
|
||||
ItemFields.MediaSources,
|
||||
ItemFields.MediaSourceCount,
|
||||
ItemFields.MediaStreams,
|
||||
]);
|
||||
|
||||
const opacity = useSharedValue(1);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { type Bitrate } from "@/components/BitrateSelector";
|
||||
import { ItemImage } from "@/components/common/ItemImage";
|
||||
import { DownloadSingleItem } from "@/components/DownloadItem";
|
||||
import { MediaSourceButton } from "@/components/MediaSourceButton";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
// const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null;
|
||||
@@ -23,19 +24,16 @@ import { CurrentSeries } from "@/components/series/CurrentSeries";
|
||||
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
import { BitrateSheet } from "./BitRateSheet";
|
||||
import { ItemHeader } from "./ItemHeader";
|
||||
import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
|
||||
import { MediaSourceSheet } from "./MediaSourceSheet";
|
||||
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
|
||||
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
|
||||
import { TrackSheet } from "./TrackSheet";
|
||||
|
||||
const Chromecast = !Platform.isTV ? require("./Chromecast") : null;
|
||||
|
||||
@@ -70,6 +68,9 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
SelectedOptions | undefined
|
||||
>(undefined);
|
||||
|
||||
// preload media sources
|
||||
useItemQuery(item.Id, false, undefined, []);
|
||||
|
||||
const {
|
||||
defaultAudioIndex,
|
||||
defaultBitrate,
|
||||
@@ -201,76 +202,27 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
}
|
||||
>
|
||||
<View className='flex flex-col bg-transparent shrink'>
|
||||
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
|
||||
<View className='flex flex-col px-4 w-full pt-2 mb-2 shrink'>
|
||||
<ItemHeader item={item} className='mb-2' />
|
||||
{item.Type !== "Program" && !Platform.isTV && !isOffline && (
|
||||
<View className='flex flex-row items-center justify-start w-full h-16 mb-2'>
|
||||
<BitrateSheet
|
||||
className='mr-1'
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, bitrate: val },
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.bitrate}
|
||||
/>
|
||||
<MediaSourceSheet
|
||||
className='mr-1'
|
||||
|
||||
<View className='flex flex-row px-0 mb-2 justify-between space-x-2'>
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
{!isOffline && (
|
||||
<MediaSourceButton
|
||||
selectedOptions={selectedOptions}
|
||||
setSelectedOptions={setSelectedOptions}
|
||||
item={item}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
mediaSource: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.mediaSource}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<TrackSheet
|
||||
className='mr-1'
|
||||
streamType='Audio'
|
||||
title={t("item_card.audio")}
|
||||
source={selectedOptions.mediaSource}
|
||||
onChange={(val) => {
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
audioIndex: val,
|
||||
},
|
||||
);
|
||||
}}
|
||||
selected={selectedOptions.audioIndex}
|
||||
/>
|
||||
<TrackSheet
|
||||
source={selectedOptions.mediaSource}
|
||||
streamType='Subtitle'
|
||||
title={t("item_card.subtitles")}
|
||||
onChange={(val) =>
|
||||
setSelectedOptions(
|
||||
(prev) =>
|
||||
prev && {
|
||||
...prev,
|
||||
subtitleIndex: val,
|
||||
},
|
||||
)
|
||||
}
|
||||
selected={selectedOptions.subtitleIndex}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayButton
|
||||
className='grow'
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
@@ -279,9 +231,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOffline && (
|
||||
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
|
||||
)}
|
||||
<OverviewText text={item.Overview} className='px-4 mb-4' />
|
||||
|
||||
{item.Type !== "Program" && (
|
||||
|
||||
203
components/MediaSourceButton.tsx
Normal file
203
components/MediaSourceButton.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||
import { BITRATES } from "./BitRateSheet";
|
||||
import type { SelectedOptions } from "./ItemContent";
|
||||
import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown";
|
||||
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
setSelectedOptions: React.Dispatch<
|
||||
React.SetStateAction<SelectedOptions | undefined>
|
||||
>;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
export const MediaSourceButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
colors,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: itemWithSources, isLoading } = useItemQuery(
|
||||
item.Id,
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
|
||||
const effectiveColors = colors || {
|
||||
primary: "#7c3aed",
|
||||
text: "#000000",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const firstMediaSource = itemWithSources?.MediaSources?.[0];
|
||||
if (!firstMediaSource) return;
|
||||
setSelectedOptions((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
mediaSource: firstMediaSource,
|
||||
};
|
||||
});
|
||||
}, [itemWithSources, setSelectedOptions]);
|
||||
|
||||
const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||
if (source.Name) return source.Name;
|
||||
if (videoStream?.DisplayTitle) return videoStream.DisplayTitle;
|
||||
return `Source ${source.Id}`;
|
||||
}, []);
|
||||
|
||||
const audioStreams = useMemo(
|
||||
() =>
|
||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Audio",
|
||||
) || [],
|
||||
[selectedOptions.mediaSource],
|
||||
);
|
||||
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
selectedOptions.mediaSource?.MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
) || [],
|
||||
[selectedOptions.mediaSource],
|
||||
);
|
||||
|
||||
const optionGroups: OptionGroup[] = useMemo(() => {
|
||||
const groups: OptionGroup[] = [];
|
||||
|
||||
// Bitrate group
|
||||
groups.push({
|
||||
title: t("item_card.quality"),
|
||||
options: BITRATES.map((bitrate) => ({
|
||||
type: "radio" as const,
|
||||
label: bitrate.key,
|
||||
value: bitrate,
|
||||
selected: bitrate.value === selectedOptions.bitrate?.value,
|
||||
onPress: () =>
|
||||
setSelectedOptions((prev) => prev && { ...prev, bitrate }),
|
||||
})),
|
||||
});
|
||||
|
||||
// Media Source group (only if multiple sources)
|
||||
if (
|
||||
itemWithSources?.MediaSources &&
|
||||
itemWithSources.MediaSources.length > 1
|
||||
) {
|
||||
groups.push({
|
||||
title: t("item_card.video"),
|
||||
options: itemWithSources.MediaSources.map((source) => ({
|
||||
type: "radio" as const,
|
||||
label: getMediaSourceDisplayName(source),
|
||||
value: source,
|
||||
selected: source.Id === selectedOptions.mediaSource?.Id,
|
||||
onPress: () =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, mediaSource: source },
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Audio track group
|
||||
if (audioStreams.length > 0) {
|
||||
groups.push({
|
||||
title: t("item_card.audio"),
|
||||
options: audioStreams.map((stream) => ({
|
||||
type: "radio" as const,
|
||||
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||
value: stream.Index,
|
||||
selected: stream.Index === selectedOptions.audioIndex,
|
||||
onPress: () =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 },
|
||||
),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Subtitle track group (with None option)
|
||||
if (subtitleStreams.length > 0) {
|
||||
const noneOption = {
|
||||
type: "radio" as const,
|
||||
label: t("common.none"),
|
||||
value: -1,
|
||||
selected: selectedOptions.subtitleIndex === -1,
|
||||
onPress: () =>
|
||||
setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }),
|
||||
};
|
||||
|
||||
const subtitleOptions = subtitleStreams.map((stream) => ({
|
||||
type: "radio" as const,
|
||||
label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`,
|
||||
value: stream.Index,
|
||||
selected: stream.Index === selectedOptions.subtitleIndex,
|
||||
onPress: () =>
|
||||
setSelectedOptions(
|
||||
(prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 },
|
||||
),
|
||||
}));
|
||||
|
||||
groups.push({
|
||||
title: t("item_card.subtitles"),
|
||||
options: [noneOption, ...subtitleOptions],
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [
|
||||
itemWithSources,
|
||||
selectedOptions,
|
||||
audioStreams,
|
||||
subtitleStreams,
|
||||
getMediaSourceDisplayName,
|
||||
t,
|
||||
setSelectedOptions,
|
||||
]);
|
||||
|
||||
const trigger = (
|
||||
<TouchableOpacity
|
||||
disabled={!item || isLoading}
|
||||
onPress={() => setOpen(true)}
|
||||
className='relative'
|
||||
>
|
||||
<View
|
||||
style={{ backgroundColor: effectiveColors.primary, opacity: 0.7 }}
|
||||
className='absolute w-12 h-12 rounded-full'
|
||||
/>
|
||||
<View className='w-12 h-12 rounded-full z-10 items-center justify-center'>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size='small' color={effectiveColors.text} />
|
||||
) : (
|
||||
<Ionicons name='list' size={24} color={effectiveColors.text} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformDropdown
|
||||
groups={optionGroups}
|
||||
trigger={trigger}
|
||||
title={t("item_card.media_options")}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
bottomSheetConfig={{
|
||||
enablePanDownToClose: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -184,7 +184,7 @@ const PlatformDropdownComponent = ({
|
||||
expoUIConfig,
|
||||
bottomSheetConfig,
|
||||
}: PlatformDropdownProps) => {
|
||||
const { showModal, hideModal } = useGlobalModal();
|
||||
const { showModal, hideModal, isVisible } = useGlobalModal();
|
||||
|
||||
// Handle controlled open state for Android
|
||||
useEffect(() => {
|
||||
@@ -207,6 +207,14 @@ const PlatformDropdownComponent = ({
|
||||
}
|
||||
}, [controlledOpen]);
|
||||
|
||||
// Watch for modal dismissal on Android (e.g., swipe down, backdrop tap)
|
||||
// and sync the controlled open state
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android" && controlledOpen === true && !isVisible) {
|
||||
controlledOnOpenChange?.(false);
|
||||
}
|
||||
}, [isVisible, controlledOpen, controlledOnOpenChange]);
|
||||
|
||||
if (Platform.OS === "ios") {
|
||||
return (
|
||||
<Host style={expoUIConfig?.hostStyle}>
|
||||
|
||||
@@ -358,9 +358,6 @@ export const PlayButton: React.FC<Props> = ({
|
||||
[startColor.value.text, endColor.value.text],
|
||||
),
|
||||
}));
|
||||
/**
|
||||
* *********************
|
||||
*/
|
||||
|
||||
// if (Platform.OS === "ios")
|
||||
// return (
|
||||
@@ -414,7 +411,7 @@ export const PlayButton: React.FC<Props> = ({
|
||||
accessibilityLabel='Play button'
|
||||
accessibilityHint='Tap to play the media'
|
||||
onPress={onPress}
|
||||
className={"relative"}
|
||||
className={"relative flex-1"}
|
||||
>
|
||||
<View className='absolute w-full h-full top-0 left-0 rounded-full z-10 overflow-hidden'>
|
||||
<Animated.View
|
||||
|
||||
@@ -1,28 +1,54 @@
|
||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
export const useItemQuery = (itemId: string, isOffline: boolean) => {
|
||||
// Helper to exclude specific fields
|
||||
export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||
return Object.values(ItemFields).filter(
|
||||
(field) => !fieldsToExclude.includes(field),
|
||||
);
|
||||
};
|
||||
|
||||
export const useItemQuery = (
|
||||
itemId: string | undefined,
|
||||
isOffline?: boolean,
|
||||
fields?: ItemFields[],
|
||||
excludeFields?: ItemFields[],
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { getDownloadedItemById } = useDownload();
|
||||
|
||||
// Calculate final fields: use excludeFields if provided, otherwise use fields
|
||||
const finalFields = excludeFields
|
||||
? Object.values(ItemFields).filter(
|
||||
(field) => !excludeFields.includes(field),
|
||||
)
|
||||
: fields;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["item", itemId],
|
||||
queryKey: ["item", itemId, finalFields],
|
||||
queryFn: async () => {
|
||||
if (!itemId) throw new Error("Item ID is required");
|
||||
|
||||
if (isOffline) {
|
||||
return getDownloadedItemById(itemId)?.item;
|
||||
}
|
||||
if (!api || !user || !itemId) return null;
|
||||
const res = await getUserLibraryApi(api).getItem({
|
||||
itemId: itemId,
|
||||
userId: user?.Id,
|
||||
|
||||
if (!api || !user) return null;
|
||||
|
||||
const response = await getItemsApi(api).getItems({
|
||||
ids: [itemId],
|
||||
userId: user.Id,
|
||||
...(finalFields && { fields: finalFields }),
|
||||
});
|
||||
return res.data;
|
||||
|
||||
return response.data.Items?.[0];
|
||||
},
|
||||
staleTime: 0,
|
||||
enabled: !!itemId,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
|
||||
@@ -512,7 +512,7 @@ export const useJellyseerr = () => {
|
||||
};
|
||||
|
||||
const jellyseerrRegion = useMemo(
|
||||
() => jellyseerrUser?.settings?.discoverRegion || "US",
|
||||
() => jellyseerrUser?.settings?.region || "US",
|
||||
[jellyseerrUser],
|
||||
);
|
||||
|
||||
|
||||
@@ -337,7 +337,8 @@
|
||||
"audio": "Audio",
|
||||
"subtitle": "Subtitle",
|
||||
"play": "Play",
|
||||
"none": "None"
|
||||
"none": "None",
|
||||
"track": "Track"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -444,6 +445,7 @@
|
||||
"no_similar_items_found": "No Similar Items Found",
|
||||
"video": "Video",
|
||||
"more_details": "More Details",
|
||||
"media_options": "Media Options",
|
||||
"quality": "Quality",
|
||||
"audio": "Audio",
|
||||
"subtitles": "Subtitle",
|
||||
|
||||
Submodule utils/jellyseerr updated: fc6a9e952c...4401b16414
Reference in New Issue
Block a user