diff --git a/.gitignore b/.gitignore index ea2b4be6..b6d9c1a0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ streamyfin-4fec1-firebase-adminsdk.json .env.local *.aab /version-backup-* +bun.lockb \ No newline at end of file diff --git a/components/BitRateSheet.tsx b/components/BitRateSheet.tsx new file mode 100644 index 00000000..0589cd05 --- /dev/null +++ b/components/BitRateSheet.tsx @@ -0,0 +1,122 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { Text } from "./common/Text"; +import { FilterSheet } from "./filters/FilterSheet"; + +export type Bitrate = { + key: string; + value: number | undefined; +}; + +export const BITRATES: Bitrate[] = [ + { + key: "Max", + value: undefined, + }, + { + key: "8 Mb/s", + value: 8000000, + height: 1080, + }, + { + key: "4 Mb/s", + value: 4000000, + height: 1080, + }, + { + key: "2 Mb/s", + value: 2000000, + }, + { + key: "1 Mb/s", + value: 1000000, + }, + { + key: "500 Kb/s", + value: 500000, + }, + { + key: "250 Kb/s", + value: 250000, + }, +].sort( + (a, b) => + (b.value || Number.POSITIVE_INFINITY) - + (a.value || Number.POSITIVE_INFINITY), +); + +interface Props extends React.ComponentProps { + onChange: (value: Bitrate) => void; + selected?: Bitrate | null; + inverted?: boolean | null; +} + +export const BitrateSheet: React.FC = ({ + onChange, + selected, + inverted, + ...props +}) => { + const isTv = Platform.isTV; + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const sorted = useMemo(() => { + if (inverted) + return BITRATES.slice().sort( + (a, b) => + (a.value || Number.POSITIVE_INFINITY) - + (b.value || Number.POSITIVE_INFINITY), + ); + return BITRATES.slice().sort( + (a, b) => + (b.value || Number.POSITIVE_INFINITY) - + (a.value || Number.POSITIVE_INFINITY), + ); + }, [inverted]); + + if (isTv) return null; + + return ( + + + + {t("item_card.quality")} + + setOpen(true)} + > + + {BITRATES.find((b) => b.value === selected?.value)?.key} + + + + + { + const label = (item as any).key || ""; + return label.toLowerCase().includes(query.toLowerCase()); + }} + renderItemLabel={(item) => {(item as any).key || ""}} + set={(vals) => { + const chosen = vals[0] as Bitrate | undefined; + if (chosen) onChange(chosen); + }} + /> + + ); +}; diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index d6c1305b..d58d1bf2 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -6,10 +6,10 @@ import { Image } from "expo-image"; import { useNavigation } from "expo-router"; import { useAtom } from "jotai"; import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AudioTrackSelector } from "@/components/AudioTrackSelector"; -import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector"; +import { type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { DownloadSingleItem } from "@/components/DownloadItem"; import { OverviewText } from "@/components/OverviewText"; @@ -18,7 +18,6 @@ import { ParallaxScrollView } from "@/components/ParallaxPage"; import { PlayButton } from "@/components/PlayButton"; import { PlayedStatus } from "@/components/PlayedStatus"; import { SimilarItems } from "@/components/SimilarItems"; -import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector"; import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; @@ -30,11 +29,13 @@ 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 { MediaSourceSelector } from "./MediaSourceSelector"; +import { MediaSourceSheet } from "./MediaSourceSheet"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; +import { TrackSheet } from "./TrackSheet"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; @@ -58,6 +59,7 @@ export const ItemContent: React.FC = React.memo( const navigation = useNavigation(); const insets = useSafeAreaInsets(); const [user] = useAtom(userAtom); + const { t } = useTranslation(); useImageColors({ item }); @@ -189,7 +191,7 @@ export const ItemContent: React.FC = React.memo( {item.Type !== "Program" && !Platform.isTV && !isOffline && ( - setSelectedOptions( @@ -198,7 +200,7 @@ export const ItemContent: React.FC = React.memo( } selected={selectedOptions.bitrate} /> - @@ -212,8 +214,10 @@ export const ItemContent: React.FC = React.memo( } selected={selectedOptions.mediaSource} /> - { setSelectedOptions( @@ -226,8 +230,10 @@ export const ItemContent: React.FC = React.memo( }} selected={selectedOptions.audioIndex} /> - setSelectedOptions( (prev) => diff --git a/components/MediaSourceSheet.tsx b/components/MediaSourceSheet.tsx new file mode 100644 index 00000000..54327b67 --- /dev/null +++ b/components/MediaSourceSheet.tsx @@ -0,0 +1,75 @@ +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { Text } from "./common/Text"; +import { FilterSheet } from "./filters/FilterSheet"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: MediaSourceInfo) => void; + selected?: MediaSourceInfo | null; +} + +export const MediaSourceSheet: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + const isTv = Platform.isTV; + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const getDisplayName = useCallback((source: MediaSourceInfo) => { + const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); + if (videoStream?.DisplayTitle) return videoStream.DisplayTitle; + if (source.Name) return source.Name; + return `Source ${source.Id}`; + }, []); + + const selectedName = useMemo(() => { + if (!selected) return ""; + return getDisplayName(selected); + }, [selected, getDisplayName]); + + if (isTv || (item.MediaStreams && item.MediaStreams.length <= 1)) return null; + + return ( + + + {t("item_card.video")} + setOpen(true)} + > + {selectedName} + + + + + getDisplayName(src as MediaSourceInfo) + .toLowerCase() + .includes(query.toLowerCase()) + } + renderItemLabel={(src) => ( + {getDisplayName(src as MediaSourceInfo)} + )} + set={(vals) => { + const chosen = vals[0] as MediaSourceInfo | undefined; + if (chosen) onChange(chosen); + }} + /> + + ); +}; diff --git a/components/TrackSheet.tsx b/components/TrackSheet.tsx new file mode 100644 index 00000000..59ddf515 --- /dev/null +++ b/components/TrackSheet.tsx @@ -0,0 +1,76 @@ +import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { Text } from "./common/Text"; +import { FilterSheet } from "./filters/FilterSheet"; + +interface Props extends React.ComponentProps { + source?: MediaSourceInfo; + onChange: (value: number) => void; + selected?: number | undefined; + streamType?: string; + title: string; +} + +export const TrackSheet: React.FC = ({ + source, + onChange, + selected, + streamType, + title, + ...props +}) => { + const isTv = Platform.isTV; + const { t } = useTranslation(); + + const streams = useMemo( + () => source?.MediaStreams?.filter((x) => x.Type === streamType), + [source], + ); + + const selectedSteam = useMemo( + () => streams?.find((x) => x.Index === selected), + [streams, selected], + ); + const [open, setOpen] = useState(false); + + if (isTv || (streams && streams.length === 0)) return null; + + return ( + + + {title} + setOpen(true)} + > + + {selectedSteam?.DisplayTitle || t("common.select", "Select")} + + + + { + const label = (item as any).DisplayTitle || ""; + return label.toLowerCase().includes(query.toLowerCase()); + }} + renderItemLabel={(item) => ( + {(item as any).DisplayTitle || ""} + )} + set={(vals) => { + const chosen = vals[0] as any; + if (chosen && chosen.Index !== null && chosen.Index !== undefined) { + onChange(chosen.Index); + } + }} + /> + + ); +}; diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx index 2d50aa0f..27e54a62 100644 --- a/components/filters/FilterSheet.tsx +++ b/components/filters/FilterSheet.tsx @@ -5,6 +5,7 @@ import { BottomSheetModal, BottomSheetScrollView, } from "@gorhom/bottom-sheet"; +import { isEqual } from "lodash"; import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -27,7 +28,7 @@ interface Props extends ViewProps { title: string; searchFilter?: (item: T, query: string) => boolean; renderItemLabel: (item: T) => React.ReactNode; - showSearch?: boolean; + disableSearch?: boolean; multiple?: boolean; } @@ -49,7 +50,7 @@ const LIMIT = 100; * @param {string} props.title - The title of the bottom sheet * @param {function} props.searchFilter - Function to filter items based on search query * @param {function} props.renderItemLabel - Function to render the label for each item - * @param {boolean} [props.showSearch=true] - Whether to show the search input + * @param {boolean} [props.disableSearch=false] - Whether to disable the search input * * @returns {React.ReactElement} The FilterSheet component * @@ -70,11 +71,11 @@ export const FilterSheet = ({ title, searchFilter, renderItemLabel, - showSearch = true, + disableSearch = false, multiple = false, }: Props) => { const bottomSheetModalRef = useRef(null); - const snapPoints = useMemo(() => ["80%"], []); + const snapPoints = useMemo(() => ["85%"], []); const { t } = useTranslation(); const [data, setData] = useState([]); @@ -82,6 +83,8 @@ export const FilterSheet = ({ const [search, setSearch] = useState(""); + const [showSearch, setShowSearch] = useState(false); + const filteredData = useMemo(() => { if (!search) return _data; const results = []; @@ -93,6 +96,13 @@ export const FilterSheet = ({ return results.slice(0, 100); }, [search, _data, searchFilter]); + useEffect(() => { + if (!data || data.length === 0 || disableSearch) return; + if (data.length > 15) { + setShowSearch(true); + } + }, [data]); + // Loads data in batches of LIMIT size, starting from offset, // to implement efficient "load more" functionality useEffect(() => { @@ -159,7 +169,7 @@ export const FilterSheet = ({ {showSearch && ( { setSearch(text); @@ -196,8 +206,8 @@ export const FilterSheet = ({ }} className=' bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between' > - {renderItemLabel(item)} - {values.some((i) => i === item) ? ( + {renderItemLabel(item)} + {values.some((i) => isEqual(i, item)) ? ( ) : ( diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx index ac7501c7..39e1178b 100644 --- a/components/video-player/controls/dropdown/DropdownView.tsx +++ b/components/video-player/controls/dropdown/DropdownView.tsx @@ -116,30 +116,32 @@ const DropdownView = () => { ))} - - - Audio - - - {audioTracks?.map((track, idx: number) => ( - track.setTrack()} - > - - {track.name} - - - ))} - - + {(audioTracks?.length ?? 0) > 0 && ( + + + Audio + + + {audioTracks?.map((track, idx: number) => ( + track.setTrack()} + > + + {track.name} + + + ))} + + + )} ); diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts index c49aff1f..b9ab1d44 100644 --- a/hooks/useDefaultPlaySettings.ts +++ b/hooks/useDefaultPlaySettings.ts @@ -26,7 +26,11 @@ const useDefaultPlaySettings = ( )?.Index; // 4. Get default bitrate from settings or fallback to max - const bitrate = settings?.defaultBitrate ?? BITRATES[0]; + let bitrate = settings?.defaultBitrate ?? BITRATES[0]; + // value undefined seems to get lost in settings. This is just a failsafe + if (bitrate.key === BITRATES[0].key) { + bitrate = BITRATES[0]; + } return { defaultAudioIndex: