Hotfix/offline playback remaining bugs (#937)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
Alex
2025-08-16 18:11:55 +10:00
committed by GitHub
parent b7221e5599
commit 3b53d76a18
7 changed files with 314 additions and 164 deletions

View File

@@ -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();

View File

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

View File

@@ -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<DownloadProps> = ({
useDownload();
const downloadedFiles = getDownloadedItems();
const [selectedMediaSource, setSelectedMediaSource] = useState<
MediaSourceInfo | undefined | null
const [selectedOptions, setSelectedOptions] = useState<
SelectedOptions | undefined
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
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<DownloadProps> = ({
[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<DownloadProps> = ({
!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<DownloadProps> = ({
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<DownloadProps> = ({
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<DownloadProps> = ({
);
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<DownloadProps> = ({
),
[],
);
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<DownloadProps> = ({
<View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector
inverted
onChange={setMaxBitrate}
selected={maxBitrate}
onChange={(val) =>
setSelectedOptions(
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/>
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
@@ -345,27 +364,51 @@ export const DownloadItems: React.FC<DownloadProps> = ({
</View>
)}
{itemsNotDownloaded.length === 1 && (
<>
<View>
<MediaSourceSelector
item={items[0]}
onChange={setSelectedMediaSource}
selected={selectedMediaSource}
onChange={(val) =>
setSelectedOptions(
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/>
{selectedMediaSource && (
{selectedOptions?.mediaSource && (
<View className='flex flex-col space-y-2'>
<AudioTrackSelector
source={selectedMediaSource}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/>
<SubtitleTrackSelector
source={selectedMediaSource}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
source={selectedOptions.mediaSource}
onChange={(val) => {
setSelectedOptions(
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
);
}}
selected={selectedOptions.subtitleIndex}
/>
</View>
)}
</>
</View>
)}
</View>

View File

@@ -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<Props> = ({
}) => {
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<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{`${name(source.Name)}`}
{getDisplayName(source)}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}

View File

@@ -181,12 +181,11 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
}
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<VideoProviderProps> = ({
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);
}
};

View File

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

View File

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