From 3b53d76a185fd94c48bc84701cc837bee50a56ae Mon Sep 17 00:00:00 2001 From: Alex <111128610+Alexk2309@users.noreply.github.com> Date: Sat, 16 Aug 2025 18:11:55 +1000 Subject: [PATCH] Hotfix/offline playback remaining bugs (#937) Co-authored-by: Alex Kim --- app/(auth)/player/direct-player.tsx | 4 + bun.lock | 1 + components/DownloadItem.tsx | 139 ++++++--- components/MediaSourceSelector.tsx | 47 ++- .../controls/contexts/VideoContext.tsx | 12 +- package.json | 1 + utils/jellyfin/media/getStreamUrl.ts | 274 ++++++++++++------ 7 files changed, 314 insertions(+), 164 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index c7cff282..10200354 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -471,6 +471,10 @@ export default function page() { playbackManager.reportPlaybackProgress( item.Id, msToTicks(progress.get()), + { + AudioStreamIndex: audioIndex ?? -1, + SubtitleStreamIndex: subtitleIndex ?? -1, + }, ); } if (!Platform.isTV) await deactivateKeepAwake(); diff --git a/bun.lock b/bun.lock index 627a1675..c1997a58 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "react-native-ios-context-menu": "^3.1.0", "react-native-ios-utilities": "5.1.8", "react-native-mmkv": "2.12.2", + "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~3.16.7", "react-native-reanimated-carousel": "4.0.2", "react-native-safe-area-context": "5.4.0", diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 990f9f0d..6ba0987f 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -9,13 +9,14 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { type Href, router, useFocusEffect } from "expo-router"; +import { type Href, router } from "expo-router"; import { t } from "i18next"; import { useAtom } from "jotai"; import type React from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { toast } from "sonner-native"; +import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { queueAtom } from "@/utils/atoms/queue"; @@ -32,6 +33,13 @@ import ProgressCircle from "./ProgressCircle"; import { RoundButton } from "./RoundButton"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; +export type SelectedOptions = { + bitrate: Bitrate; + mediaSource: MediaSourceInfo | undefined; + audioIndex: number | undefined; + subtitleIndex: number; +}; + interface DownloadProps extends ViewProps { items: BaseItemDto[]; MissingDownloadIconComponent: () => React.ReactElement; @@ -60,18 +68,16 @@ export const DownloadItems: React.FC = ({ useDownload(); const downloadedFiles = getDownloadedItems(); - const [selectedMediaSource, setSelectedMediaSource] = useState< - MediaSourceInfo | undefined | null + const [selectedOptions, setSelectedOptions] = useState< + SelectedOptions | undefined >(undefined); - const [selectedAudioStream, setSelectedAudioStream] = useState(-1); - const [selectedSubtitleStream, setSelectedSubtitleStream] = - useState(0); - const [maxBitrate, setMaxBitrate] = useState( - settings?.defaultBitrate ?? { - key: "Max", - value: undefined, - }, - ); + + const { + defaultAudioIndex, + defaultBitrate, + defaultMediaSource, + defaultSubtitleIndex, + } = useDefaultPlaySettings(items[0], settings); const userCanDownload = useMemo( () => user?.Policy?.EnableContentDownloading, @@ -98,6 +104,24 @@ export const DownloadItems: React.FC = ({ [items, downloadedFiles], ); + // Initialize selectedOptions with default values + useEffect(() => { + if (itemsNotDownloaded.length === 1) { + setSelectedOptions(() => ({ + bitrate: defaultBitrate, + mediaSource: defaultMediaSource, + subtitleIndex: defaultSubtitleIndex ?? -1, + audioIndex: defaultAudioIndex, + })); + } + }, [ + defaultAudioIndex, + defaultBitrate, + defaultSubtitleIndex, + defaultMediaSource, + itemsNotDownloaded.length, + ]); + const itemsToDownload = useMemo(() => { if (downloadUnwatchedOnly) { return itemsNotDownloaded.filter((item) => !item.UserData?.Played); @@ -153,7 +177,7 @@ export const DownloadItems: React.FC = ({ !api || !user?.Id || items.some((p) => !p.Id) || - (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id) + (itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id) ) { throw new Error( "DownloadItem ~ initiateDownload: No api or user or item", @@ -164,9 +188,9 @@ export const DownloadItems: React.FC = ({ itemsNotDownloaded.length > 1 ? getDefaultPlaySettings(item, settings!) : { - mediaSource: selectedMediaSource, - audioIndex: selectedAudioStream, - subtitleIndex: selectedSubtitleStream, + mediaSource: selectedOptions?.mediaSource, + audioIndex: selectedOptions?.audioIndex, + subtitleIndex: selectedOptions?.subtitleIndex, }; const downloadDetails = await getDownloadUrl({ @@ -176,7 +200,7 @@ export const DownloadItems: React.FC = ({ mediaSource: mediaSource!, audioStreamIndex: audioIndex ?? -1, subtitleStreamIndex: subtitleIndex ?? -1, - maxBitrate, + maxBitrate: selectedOptions?.bitrate || defaultBitrate, deviceId: api.deviceInfo.id, }); @@ -205,18 +229,21 @@ export const DownloadItems: React.FC = ({ ); continue; } - await startBackgroundDownload(url, item, mediaSource, maxBitrate); + await startBackgroundDownload( + url, + item, + mediaSource, + selectedOptions?.bitrate || defaultBitrate, + ); } }, [ api, user?.Id, itemsNotDownloaded, - selectedMediaSource, - selectedAudioStream, - selectedSubtitleStream, + selectedOptions, settings, - maxBitrate, + defaultBitrate, startBackgroundDownload, ], ); @@ -246,18 +273,6 @@ export const DownloadItems: React.FC = ({ ), [], ); - useFocusEffect( - useCallback(() => { - if (!settings) return; - if (itemsNotDownloaded.length !== 1) return; - const { bitrate, mediaSource, audioIndex, subtitleIndex } = - getDefaultPlaySettings(items[0], settings); - setSelectedMediaSource(mediaSource ?? undefined); - setSelectedAudioStream(audioIndex ?? 0); - setSelectedSubtitleStream(subtitleIndex ?? -1); - setMaxBitrate(bitrate); - }, [items, itemsNotDownloaded, settings]), - ); const renderButtonContent = () => { if (processes.length > 0 && itemsProcesses.length > 0) { @@ -332,8 +347,12 @@ export const DownloadItems: React.FC = ({ + setSelectedOptions( + (prev) => prev && { ...prev, bitrate: val }, + ) + } + selected={selectedOptions?.bitrate} /> {itemsNotDownloaded.length > 1 && ( @@ -345,27 +364,51 @@ export const DownloadItems: React.FC = ({ )} {itemsNotDownloaded.length === 1 && ( - <> + + setSelectedOptions( + (prev) => + prev && { + ...prev, + mediaSource: val, + }, + ) + } + selected={selectedOptions?.mediaSource} /> - {selectedMediaSource && ( + {selectedOptions?.mediaSource && ( { + setSelectedOptions( + (prev) => + prev && { + ...prev, + audioIndex: val, + }, + ); + }} + selected={selectedOptions.audioIndex} /> { + setSelectedOptions( + (prev) => + prev && { + ...prev, + subtitleIndex: val, + }, + ); + }} + selected={selectedOptions.subtitleIndex} /> )} - + )} diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx index ed3e41b5..3125f654 100644 --- a/components/MediaSourceSelector.tsx +++ b/components/MediaSourceSelector.tsx @@ -2,7 +2,7 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { Platform, TouchableOpacity, View } from "react-native"; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; @@ -24,36 +24,27 @@ export const MediaSourceSelector: React.FC = ({ }) => { const isTv = Platform.isTV; - const selectedName = useMemo( - () => - item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find( - (x) => x.Type === "Video", - )?.DisplayTitle || "", - [item, selected], - ); - const { t } = useTranslation(); - const commonPrefix = useMemo(() => { - const mediaSources = item.MediaSources || []; - if (!mediaSources.length) return ""; - - let commonPrefix = ""; - for (let i = 0; i < mediaSources[0].Name!.length; i++) { - const char = mediaSources[0].Name![i]; - if (mediaSources.every((source) => source.Name![i] === char)) { - commonPrefix += char; - } else { - commonPrefix = commonPrefix.slice(0, -1); - break; - } + const getDisplayName = useCallback((source: MediaSourceInfo) => { + const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); + if (videoStream?.DisplayTitle) { + return videoStream.DisplayTitle; } - return commonPrefix; - }, [item.MediaSources]); - const name = (name?: string | null) => { - return name?.replace(commonPrefix, "").toLowerCase(); - }; + // Fallback to source name + if (source.Name) { + return source.Name; + } + + // Last resort fallback + return `Source ${source.Id}`; + }, []); + + const selectedName = useMemo(() => { + if (!selected) return ""; + return getDisplayName(selected); + }, [selected, getDisplayName]); if (isTv) return null; @@ -93,7 +84,7 @@ export const MediaSourceSelector: React.FC = ({ }} > - {`${name(source.Name)}`} + {getDisplayName(source)} ))} diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx index 2317f36a..3940d520 100644 --- a/components/video-player/controls/contexts/VideoContext.tsx +++ b/components/video-player/controls/contexts/VideoContext.tsx @@ -181,12 +181,11 @@ export const VideoProvider: React.FC = ({ } if (getAudioTracks) { const audioData = await getAudioTracks(); - const allAudio = mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || []; const audioTracks: Track[] = allAudio?.map((audio, idx) => { if (!mediaSource?.TranscodingUrl) { - const vlcIndex = audioData?.at(idx)?.index ?? -1; + const vlcIndex = audioData?.at(idx + 1)?.index ?? -1; return { name: audio.DisplayTitle ?? "Undefined Audio", index: audio.Index ?? -1, @@ -201,6 +200,15 @@ export const VideoProvider: React.FC = ({ setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }), }; }); + + // Add a "Disable Audio" option if its not transcoding. + if (!mediaSource?.TranscodingUrl) { + audioTracks.unshift({ + name: "Disable", + index: -1, + setTrack: () => setTrackParams("audio", -1, -1), + }); + } setAudioTracks(audioTracks); } }; diff --git a/package.json b/package.json index 44d4c0c4..80604faf 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-native-ios-context-menu": "^3.1.0", "react-native-ios-utilities": "5.1.8", "react-native-mmkv": "2.12.2", + "react-native-pager-view": "^6.9.1", "react-native-reanimated": "~3.16.7", "react-native-reanimated-carousel": "4.0.2", "react-native-safe-area-context": "5.4.0", diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts index d1497ded..75300831 100644 --- a/utils/jellyfin/media/getStreamUrl.ts +++ b/utils/jellyfin/media/getStreamUrl.ts @@ -3,9 +3,136 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; +import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind"; import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; import download from "@/utils/profiles/download"; +interface StreamResult { + url: string; + sessionId: string | null; + mediaSource: MediaSourceInfo | undefined; +} + +/** + * Gets the actual streaming URL - handles both transcoded and direct play logic + * Returns only the URL string + */ +const getPlaybackUrl = ( + api: Api, + itemId: string, + mediaSource: MediaSourceInfo | undefined, + params: { + subtitleStreamIndex?: number; + audioStreamIndex?: number; + deviceId?: string | null; + startTimeTicks?: number; + maxStreamingBitrate?: number; + userId: string; + playSessionId?: string | null; + container?: string; + static?: string; + }, +): string => { + let transcodeUrl = mediaSource?.TranscodingUrl; + + // Handle transcoded URL if available + if (transcodeUrl) { + // For regular streaming, change subtitle method to HLS for transcoded URL + if (params.subtitleStreamIndex === -1) { + transcodeUrl = transcodeUrl.replace( + "SubtitleMethod=Encode", + "SubtitleMethod=Hls", + ); + } + + console.log("Video is being transcoded:", transcodeUrl); + return `${api.basePath}${transcodeUrl}`; + } + + // Fall back to direct play + const streamParams = new URLSearchParams({ + static: params.static || "true", + container: params.container || "mp4", + mediaSourceId: mediaSource?.Id || "", + subtitleStreamIndex: params.subtitleStreamIndex?.toString() || "", + audioStreamIndex: params.audioStreamIndex?.toString() || "", + deviceId: params.deviceId || api.deviceInfo.id, + api_key: api.accessToken, + startTimeTicks: params.startTimeTicks?.toString() || "0", + maxStreamingBitrate: params.maxStreamingBitrate?.toString() || "", + userId: params.userId, + }); + + // Add additional parameters if provided + if (params.playSessionId) { + streamParams.append("playSessionId", params.playSessionId); + } + + const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`; + + console.log("Video is being direct played:", directPlayUrl); + return directPlayUrl; +}; + +/** Wrapper around {@link getPlaybackUrl} that applies download-specific transformations */ +const getDownloadUrl = ( + api: Api, + itemId: string, + mediaSource: MediaSourceInfo | undefined, + sessionId: string | null | undefined, + params: { + subtitleStreamIndex?: number; + audioStreamIndex?: number; + deviceId?: string | null; + startTimeTicks?: number; + maxStreamingBitrate?: number; + userId: string; + playSessionId?: string | null; + }, +): StreamResult => { + // First, handle download-specific transcoding modifications + let downloadMediaSource = mediaSource; + if (mediaSource?.TranscodingUrl) { + downloadMediaSource = { + ...mediaSource, + TranscodingUrl: mediaSource.TranscodingUrl.replace( + "master.m3u8", + "stream", + ), + }; + } + + // Get the base URL with download-specific parameters + let url = getPlaybackUrl(api, itemId, downloadMediaSource, { + ...params, + container: "ts", + static: "false", + }); + + // If it's a direct play URL, add download-specific parameters + if (!mediaSource?.TranscodingUrl) { + const urlObj = new URL(url); + const downloadParams = { + subtitleMethod: "Embed", + enableSubtitlesInManifest: "true", + allowVideoStreamCopy: "true", + allowAudioStreamCopy: "true", + }; + + Object.entries(downloadParams).forEach(([key, value]) => { + urlObj.searchParams.append(key, value); + }); + + url = urlObj.toString(); + } + + return { + url, + sessionId: sessionId || null, + mediaSource, + }; +}; + export const getStreamUrl = async ({ api, item, @@ -44,6 +171,47 @@ export const getStreamUrl = async ({ let mediaSource: MediaSourceInfo | undefined; let sessionId: string | null | undefined; + // Please do not remove this we need this for live TV to be working correctly. + if (item.Type === BaseItemKind.Program) { + console.log("Item is of type program..."); + const res = await getMediaInfoApi(api).getPlaybackInfo( + { + userId, + itemId: item.ChannelId!, + }, + { + method: "POST", + params: { + startTimeTicks: 0, + isPlayback: true, + autoOpenLiveStream: true, + maxStreamingBitrate, + audioStreamIndex, + }, + data: { + deviceProfile, + }, + }, + ); + + sessionId = res.data.PlaySessionId || null; + mediaSource = res.data.MediaSources?.[0]; + const url = getPlaybackUrl(api, item.ChannelId!, mediaSource, { + subtitleStreamIndex, + audioStreamIndex, + deviceId, + startTimeTicks: 0, + maxStreamingBitrate, + userId, + }); + + return { + url, + sessionId: sessionId || null, + mediaSource, + }; + } + const res = await getMediaInfoApi(api).getPlaybackInfo( { itemId: item.Id!, @@ -70,46 +238,20 @@ export const getStreamUrl = async ({ sessionId = res.data.PlaySessionId || null; mediaSource = res.data.MediaSources?.[0]; - let transcodeUrl = mediaSource?.TranscodingUrl; - if (transcodeUrl) { - // We need to change the subtitle method to hls for the transcoded url. - if (subtitleStreamIndex === -1) { - transcodeUrl = transcodeUrl.replace( - "SubtitleMethod=Encode", - "SubtitleMethod=Hls", - ); - } - console.log("Video is being transcoded:", transcodeUrl); - return { - url: `${api.basePath}${transcodeUrl}`, - sessionId, - mediaSource, - }; - } - - const streamParams = new URLSearchParams({ - static: "true", - container: "mp4", - mediaSourceId: mediaSource?.Id || "", - subtitleStreamIndex: subtitleStreamIndex?.toString() || "", - audioStreamIndex: audioStreamIndex?.toString() || "", - deviceId: deviceId || api.deviceInfo.id, - api_key: api.accessToken, - startTimeTicks: startTimeTicks.toString(), - maxStreamingBitrate: maxStreamingBitrate?.toString() || "", - userId: userId, + const url = getPlaybackUrl(api, item.Id!, mediaSource, { + subtitleStreamIndex, + audioStreamIndex, + deviceId, + startTimeTicks, + maxStreamingBitrate, + userId, + playSessionId: playSessionId || undefined, }); - const directPlayUrl = `${ - api.basePath - }/Videos/${item.Id}/stream?${streamParams.toString()}`; - - console.log("Video is being direct played:", directPlayUrl); - return { - url: directPlayUrl, - sessionId: sessionId || playSessionId || null, + url, + sessionId: sessionId || null, mediaSource, }; }; @@ -142,9 +284,6 @@ export const getDownloadStreamUrl = async ({ return null; } - let mediaSource: MediaSourceInfo | undefined; - let sessionId: string | null | undefined; - const res = await getMediaInfoApi(api).getPlaybackInfo( { itemId: item.Id!, @@ -169,53 +308,16 @@ export const getDownloadStreamUrl = async ({ console.error("Error getting playback info:", res.status, res.statusText); } - sessionId = res.data.PlaySessionId || null; - mediaSource = res.data.MediaSources?.[0]; - let transcodeUrl = mediaSource?.TranscodingUrl; + const sessionId = res.data.PlaySessionId || null; + const mediaSource = res.data.MediaSources?.[0]; - if (transcodeUrl) { - transcodeUrl = transcodeUrl.replace("master.m3u8", "stream"); - console.log("Video is being transcoded:", transcodeUrl); - return { - url: `${api.basePath}${transcodeUrl}`, - sessionId, - mediaSource, - }; - } - - const downloadParams = { - // We need to disable static so we can have a remux with subtitle. - subtitleMethod: "Embed", - enableSubtitlesInManifest: true, - allowVideoStreamCopy: true, - allowAudioStreamCopy: true, - playSessionId: sessionId || "", - }; - - const streamParams = new URLSearchParams({ - static: "false", - container: "ts", - mediaSourceId: mediaSource?.Id || "", - subtitleStreamIndex: subtitleStreamIndex?.toString() || "", - audioStreamIndex: audioStreamIndex?.toString() || "", - deviceId: deviceId || api.deviceInfo.id, - api_key: api.accessToken, - startTimeTicks: "0", - maxStreamingBitrate: maxStreamingBitrate?.toString() || "", - userId: userId, + return getDownloadUrl(api, item.Id!, mediaSource, sessionId, { + subtitleStreamIndex, + audioStreamIndex, + deviceId, + startTimeTicks: 0, + maxStreamingBitrate, + userId, + playSessionId: sessionId || undefined, }); - - Object.entries(downloadParams).forEach(([key, value]) => { - streamParams.append(key, value.toString()); - }); - - const directPlayUrl = `${ - api.basePath - }/Videos/${item.Id}/stream?${streamParams.toString()}`; - - return { - url: directPlayUrl, - sessionId: sessionId || null, - mediaSource, - }; };