From af50b023efffadf475b74966f524deee8c4c2367 Mon Sep 17 00:00:00 2001 From: Alex Kim Date: Thu, 19 Feb 2026 18:23:45 +1100 Subject: [PATCH] Sync subtitle and audio indexes between server and offline --- app/(auth)/player/direct-player.tsx | 24 ++++++++++-- components/ItemContent.tsx | 35 ++++++++++++----- components/MediaSourceButton.tsx | 61 ++++++++++++++++++----------- components/PlayButton.tsx | 27 +++++++++++++ hooks/useDownloadedFileOpener.ts | 6 +++ hooks/usePlaybackManager.ts | 14 +++++++ 6 files changed, 131 insertions(+), 36 deletions(-) diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index a4cffe77..70b5cd75 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -134,7 +134,7 @@ export default function page() { const audioIndexFromUrl = audioIndexStr ? Number.parseInt(audioIndexStr, 10) : undefined; - const subtitleIndex = subtitleIndexStr + const subtitleIndexFromUrl = subtitleIndexStr ? Number.parseInt(subtitleIndexStr, 10) : -1; const bitrateValue = bitrateValueStr @@ -161,6 +161,24 @@ export default function page() { return undefined; }, [audioIndexFromUrl, offline, downloadedItem?.userData?.audioStreamIndex]); + // Resolve subtitle index: use URL param if provided, otherwise use stored index for offline playback + const subtitleIndex = useMemo(() => { + if (subtitleIndexFromUrl !== undefined) { + return subtitleIndexFromUrl; + } + if ( + offline && + downloadedItem?.userData?.subtitleStreamIndex !== undefined + ) { + return downloadedItem.userData.subtitleStreamIndex; + } + return -1; + }, [ + subtitleIndexFromUrl, + offline, + downloadedItem?.userData?.subtitleStreamIndex, + ]); + // Get the playback speed for this item based on settings const { playbackSpeed: initialPlaybackSpeed } = usePlaybackSpeed( item, @@ -406,8 +424,8 @@ export default function page() { return { ItemId: item.Id, - AudioStreamIndex: audioIndex ? audioIndex : undefined, - SubtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, + AudioStreamIndex: audioIndex, + SubtitleStreamIndex: subtitleIndex, MediaSourceId: mediaSourceId, PositionTicks: msToTicks(progress.get()), IsPaused: !isPlaying, diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index 1b2cbbac..d09ae043 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -25,6 +25,7 @@ import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; import { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; +import { useDownload } from "@/providers/DownloadProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { useSettings } from "@/utils/atoms/settings"; @@ -53,6 +54,7 @@ export const ItemContent: React.FC = React.memo( ({ item, itemWithSources }) => { const [api] = useAtom(apiAtom); const isOffline = useOfflineMode(); + const { getDownloadedItemById } = useDownload(); const { settings } = useSettings(); const { orientation } = useOrientation(); const navigation = useNavigation(); @@ -91,17 +93,32 @@ export const ItemContent: React.FC = React.memo( // Needs to automatically change the selected to the default values for default indexes. useEffect(() => { + // When offline, use the indices stored in userData (the last-used tracks for this file) + // rather than the server's defaults, so MediaSourceButton reflects what will actually play. + const downloadedItem = + isOffline && item.Id ? getDownloadedItemById(item.Id) : null; + const offlineUserData = downloadedItem?.userData; + setSelectedOptions(() => ({ bitrate: defaultBitrate, mediaSource: defaultMediaSource ?? undefined, - subtitleIndex: defaultSubtitleIndex ?? -1, - audioIndex: defaultAudioIndex, + subtitleIndex: + offlineUserData && !offlineUserData.isTranscoded + ? offlineUserData.subtitleStreamIndex + : (defaultSubtitleIndex ?? -1), + audioIndex: + offlineUserData && !offlineUserData.isTranscoded + ? offlineUserData.audioStreamIndex + : defaultAudioIndex, })); }, [ defaultAudioIndex, defaultBitrate, defaultSubtitleIndex, defaultMediaSource, + isOffline, + item.Id, + getDownloadedItemById, ]); useEffect(() => { @@ -232,14 +249,12 @@ export const ItemContent: React.FC = React.memo( colors={itemColors} /> - {!isOffline && ( - - )} + {item.Type === "Episode" && ( diff --git a/components/MediaSourceButton.tsx b/components/MediaSourceButton.tsx index 05847f35..2d57d057 100644 --- a/components/MediaSourceButton.tsx +++ b/components/MediaSourceButton.tsx @@ -7,6 +7,8 @@ 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 { useDownload } from "@/providers/DownloadProvider"; +import { useOfflineMode } from "@/providers/OfflineModeProvider"; import { BITRATES } from "./BitRateSheet"; import type { SelectedOptions } from "./ItemContent"; import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; @@ -28,6 +30,14 @@ export const MediaSourceButton: React.FC = ({ }: Props) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const isOffline = useOfflineMode(); + const { getDownloadedItemById } = useDownload(); + + // For transcoded downloads there's only one burned-in track — nothing to pick + const isTranscodedDownload = useMemo(() => { + if (!isOffline || !item?.Id) return false; + return getDownloadedItemById(item.Id)?.userData?.isTranscoded === true; + }, [isOffline, item?.Id, getDownloadedItemById]); const effectiveColors = colors || { primary: "#7c3aed", @@ -72,34 +82,36 @@ export const MediaSourceButton: React.FC = ({ 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 (item?.MediaSources && item.MediaSources.length > 1) { + if (!isOffline) { + // Bitrate group groups.push({ - title: t("item_card.video"), - options: item.MediaSources.map((source) => ({ + title: t("item_card.quality"), + options: BITRATES.map((bitrate) => ({ type: "radio" as const, - label: getMediaSourceDisplayName(source), - value: source, - selected: source.Id === selectedOptions.mediaSource?.Id, + label: bitrate.key, + value: bitrate, + selected: bitrate.value === selectedOptions.bitrate?.value, onPress: () => - setSelectedOptions( - (prev) => prev && { ...prev, mediaSource: source }, - ), + setSelectedOptions((prev) => prev && { ...prev, bitrate }), })), }); + + // Media Source group (only if multiple sources) + if (item?.MediaSources && item.MediaSources.length > 1) { + groups.push({ + title: t("item_card.video"), + options: item.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 @@ -150,6 +162,7 @@ export const MediaSourceButton: React.FC = ({ return groups; }, [ item, + isOffline, selectedOptions, audioStreams, subtitleStreams, @@ -178,6 +191,8 @@ export const MediaSourceButton: React.FC = ({ ); + if (isTranscodedDownload) return null; + return ( = ({ // If already in offline mode, play downloaded file directly if (isOffline && downloadedItem) { + const isTranscoded = downloadedItem.userData?.isTranscoded === true; const queryParams = new URLSearchParams({ itemId: item.Id!, offline: "true", + audioIndex: isTranscoded + ? (downloadedItem.userData?.audioStreamIndex?.toString() ?? "") + : (selectedOptions.audioIndex?.toString() ?? ""), + subtitleIndex: isTranscoded + ? (downloadedItem.userData?.subtitleStreamIndex?.toString() ?? "-1") + : (selectedOptions.subtitleIndex?.toString() ?? "-1"), playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0", }); @@ -331,9 +338,19 @@ export const PlayButton: React.FC = ({