Sync subtitle and audio indexes between server and offline

This commit is contained in:
Alex Kim
2026-02-19 18:23:45 +11:00
parent 9f9d949891
commit af50b023ef
6 changed files with 131 additions and 36 deletions

View File

@@ -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,

View File

@@ -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<ItemContentProps> = 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<ItemContentProps> = 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<ItemContentProps> = React.memo(
colors={itemColors}
/>
<View className='w-1' />
{!isOffline && (
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
colors={itemColors}
/>
)}
<MediaSourceButton
selectedOptions={selectedOptions}
setSelectedOptions={setSelectedOptions}
item={itemWithSources}
colors={itemColors}
/>
</View>
</View>
{item.Type === "Episode" && (

View File

@@ -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> = ({
}: 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<Props> = ({
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<Props> = ({
return groups;
}, [
item,
isOffline,
selectedOptions,
audioStreams,
subtitleStreams,
@@ -178,6 +191,8 @@ export const MediaSourceButton: React.FC<Props> = ({
</TouchableOpacity>
);
if (isTranscodedDownload) return null;
return (
<PlatformDropdown
groups={optionGroups}

View File

@@ -302,9 +302,16 @@ export const PlayButton: React.FC<Props> = ({
// 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<Props> = ({
<Button
onPress={() => {
hideModal();
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",
});
@@ -374,9 +391,19 @@ export const PlayButton: React.FC<Props> = ({
{
text: t("player.downloaded_file_yes"),
onPress: () => {
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",
});

View File

@@ -1,6 +1,7 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useCallback } from "react";
import useRouter from "@/hooks/useAppRouter";
import { getDownloadedItemById } from "@/providers/Downloads/database";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
@@ -15,9 +16,14 @@ export const useDownloadedFileOpener = () => {
console.error("Attempted to open a file without an ID.");
return;
}
const downloadedItem = getDownloadedItemById(item.Id);
const queryParams = new URLSearchParams({
itemId: item.Id,
offline: "true",
audioIndex:
downloadedItem?.userData?.audioStreamIndex?.toString() ?? "",
subtitleIndex:
downloadedItem?.userData?.subtitleStreamIndex?.toString() ?? "-1",
playbackPosition:
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
});

View File

@@ -186,6 +186,20 @@ export const usePlaybackManager = ({
: playedPercentage,
},
},
// Sync selected audio/subtitle tracks so next playback resumes with
// the same tracks the user had active — but only for non-transcoded
// downloads where the user can freely switch tracks.
userData: localItem.userData.isTranscoded
? localItem.userData
: {
...localItem.userData,
...(playbackProgressInfo.AudioStreamIndex != null && {
audioStreamIndex: playbackProgressInfo.AudioStreamIndex,
}),
...(playbackProgressInfo.SubtitleStreamIndex != null && {
subtitleStreamIndex: playbackProgressInfo.SubtitleStreamIndex,
}),
},
});
// Force invalidate queries so they refetch from updated local database
queryClient.invalidateQueries({ queryKey: ["item", itemId] });