From 91ed109a04a46407a583b973abdd2af81254359a Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 27 Aug 2024 08:26:27 +0200 Subject: [PATCH] feat: select media source --- components/AudioTrackSelector.tsx | 18 ++++--- components/BitrateSelector.tsx | 2 +- components/ItemContent.tsx | 43 +++++++++------ components/MediaSourceSelector.tsx | 81 ++++++++++++++++++++++++++++ components/SubtitleTrackSelector.tsx | 20 +++---- utils/jellyfin/media/getStreamUrl.ts | 13 +++-- 6 files changed, 139 insertions(+), 38 deletions(-) create mode 100644 components/MediaSourceSelector.tsx diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx index f92f4239..1d01c220 100644 --- a/components/AudioTrackSelector.tsx +++ b/components/AudioTrackSelector.tsx @@ -2,27 +2,29 @@ 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 } from "@jellyfin/sdk/lib/generated-client/models"; +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"; interface Props extends React.ComponentProps { - item: BaseItemDto; + source: MediaSourceInfo; onChange: (value: number) => void; selected: number; } export const AudioTrackSelector: React.FC = ({ - item, + source, onChange, selected, ...props }) => { const audioStreams = useMemo( - () => - item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"), - [item] + () => source.MediaStreams?.filter((x) => x.Type === "Audio"), + [source] ); const selectedAudioSteam = useMemo( @@ -31,7 +33,7 @@ export const AudioTrackSelector: React.FC = ({ ); useEffect(() => { - const index = item.MediaSources?.[0].DefaultAudioStreamIndex; + const index = source.DefaultAudioStreamIndex; if (index !== undefined && index !== null) onChange(index); }, []); @@ -44,7 +46,7 @@ export const AudioTrackSelector: React.FC = ({ - {tc(selectedAudioSteam?.DisplayTitle, 13)} + {tc(selectedAudioSteam?.DisplayTitle, 7)} diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx index 43df6fc5..45370614 100644 --- a/components/BitrateSelector.tsx +++ b/components/BitrateSelector.tsx @@ -58,7 +58,7 @@ export const BitrateSelector: React.FC = ({ Bitrate - + {BITRATES.find((b) => b.value === selected.value)?.key} diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index ecc0b109..1cc311fa 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -32,6 +32,8 @@ import React, { useEffect, useMemo, useState } from "react"; import { View } from "react-native"; import { useCastDevice } from "react-native-google-cast"; import { ItemHeader } from "./ItemHeader"; +import { MediaSourceSelector } from "./MediaSourceSelector"; +import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [api] = useAtom(apiAtom); @@ -40,6 +42,8 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { const [settings] = useSettings(); const castDevice = useCastDevice(); + const [selectedMediaSource, setSelectedMediaSource] = + useState(null); const [selectedAudioStream, setSelectedAudioStream] = useState(-1); const [selectedSubtitleStream, setSelectedSubtitleStream] = useState(0); @@ -85,6 +89,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { item?.Id, maxBitrate, castDevice, + selectedMediaSource, selectedAudioStream, selectedSubtitleStream, settings, @@ -114,9 +119,10 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { subtitleStreamIndex: selectedSubtitleStream, forceDirectPlay: settings?.forceDirectPlay, height: maxBitrate.height, + mediaSourceId: selectedMediaSource?.Id, }); - console.log("Transcode URL: ", url); + console.info("Stream URL:", url); return url; }, @@ -194,19 +200,24 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { onChange={(val) => setMaxBitrate(val)} selected={maxBitrate} /> - {item && ( - - )} - {item && ( - + + {selectedMediaSource && ( + + + + )} ) : ( @@ -219,7 +230,9 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => { - + {item?.Type === "Episode" && ( + + )} diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx new file mode 100644 index 00000000..c2a95c23 --- /dev/null +++ b/components/MediaSourceSelector.tsx @@ -0,0 +1,81 @@ +import { tc } from "@/utils/textTools"; +import { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { useEffect, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import { Text } from "./common/Text"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + onChange: (value: MediaSourceInfo) => void; + selected: MediaSourceInfo | null; +} + +export const MediaSourceSelector: React.FC = ({ + item, + onChange, + selected, + ...props +}) => { + const mediaSources = useMemo(() => { + return item.MediaSources; + }, [item]); + + const selectedMediaSource = useMemo( + () => + mediaSources + ?.find((x) => x.Id === selected?.Id) + ?.MediaStreams?.find((x) => x.Type === "Video")?.DisplayTitle || "", + [mediaSources, selected] + ); + + useEffect(() => { + if (mediaSources?.length) onChange(mediaSources[0]); + }, []); + + return ( + + + + + Video streams + + + {tc(selectedMediaSource, 7)} + + + + + + Video streams + {mediaSources?.map((source, idx: number) => ( + { + onChange(source); + }} + > + + { + source.MediaStreams?.find((s) => s.Type === "Video") + ?.DisplayTitle + } + + + ))} + + + + ); +}; diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx index a445ffeb..8141e312 100644 --- a/components/SubtitleTrackSelector.tsx +++ b/components/SubtitleTrackSelector.tsx @@ -2,29 +2,29 @@ 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 } from "@jellyfin/sdk/lib/generated-client/models"; +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"; interface Props extends React.ComponentProps { - item: BaseItemDto; + source: MediaSourceInfo; onChange: (value: number) => void; selected: number; } export const SubtitleTrackSelector: React.FC = ({ - item, + source, onChange, selected, ...props }) => { const subtitleStreams = useMemo( - () => - item.MediaSources?.[0].MediaStreams?.filter( - (x) => x.Type === "Subtitle" - ) ?? [], - [item] + () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [], + [source] ); const selectedSubtitleSteam = useMemo( @@ -33,7 +33,7 @@ export const SubtitleTrackSelector: React.FC = ({ ); useEffect(() => { - const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex; + const index = source.DefaultSubtitleStreamIndex; if (index !== undefined && index !== null) { onChange(index); } else { @@ -53,7 +53,7 @@ export const SubtitleTrackSelector: React.FC = ({ {selectedSubtitleSteam - ? tc(selectedSubtitleSteam?.DisplayTitle, 13) + ? tc(selectedSubtitleSteam?.DisplayTitle, 7) : "None"} diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index 6c5a6056..caf82a46 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -18,6 +18,7 @@ export const getStreamUrl = async ({ subtitleStreamIndex = 0, forceDirectPlay = false, height, + mediaSourceId, }: { api: Api | null | undefined; item: BaseItemDto | null | undefined; @@ -30,8 +31,10 @@ export const getStreamUrl = async ({ subtitleStreamIndex?: number; forceDirectPlay?: boolean; height?: number; + mediaSourceId?: string | null; }) => { - if (!api || !userId || !item?.Id) { + if (!api || !userId || !item?.Id || !mediaSourceId) { + console.error("Missing required parameters"); return null; } @@ -46,7 +49,7 @@ export const getStreamUrl = async ({ StartTimeTicks: startTimeTicks, EnableTranscoding: maxStreamingBitrate ? true : undefined, AutoOpenLiveStream: true, - MediaSourceId: itemId, + MediaSourceId: mediaSourceId, AllowVideoStreamCopy: maxStreamingBitrate ? false : true, AudioStreamIndex: audioStreamIndex, SubtitleStreamIndex: subtitleStreamIndex, @@ -62,7 +65,9 @@ export const getStreamUrl = async ({ } ); - const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo; + const mediaSource: MediaSourceInfo = response.data.MediaSources.find( + (source: MediaSourceInfo) => source.Id === mediaSourceId + ); if (!mediaSource) { throw new Error("No media source"); @@ -75,7 +80,7 @@ export const getStreamUrl = async ({ if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) { if (item.MediaType === "Video") { console.log("Using direct stream for video!"); - return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`; + return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true`; } else if (item.MediaType === "Audio") { console.log("Using direct stream for audio!"); const searchParams = new URLSearchParams({