mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-19 02:34:17 +01:00
Hotfix/offline playback remaining bugs (#937)
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Some checks failed
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (phone) (push) Has been cancelled
🤖 Android APK Build (Phone + TV) / 🏗️ Build Android APK (tv) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (phone) (push) Has been cancelled
🤖 iOS IPA Build (Phone + TV) / 🏗️ Build iOS IPA (tv) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🕒 Handle Stale Issues / 🗑️ Cleanup Stale Issues (push) Has been cancelled
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
This commit is contained in:
@@ -471,6 +471,10 @@ export default function page() {
|
|||||||
playbackManager.reportPlaybackProgress(
|
playbackManager.reportPlaybackProgress(
|
||||||
item.Id,
|
item.Id,
|
||||||
msToTicks(progress.get()),
|
msToTicks(progress.get()),
|
||||||
|
{
|
||||||
|
AudioStreamIndex: audioIndex ?? -1,
|
||||||
|
SubtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!Platform.isTV) await deactivateKeepAwake();
|
if (!Platform.isTV) await deactivateKeepAwake();
|
||||||
|
|||||||
1
bun.lock
1
bun.lock
@@ -66,6 +66,7 @@
|
|||||||
"react-native-ios-context-menu": "^3.1.0",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "5.1.8",
|
"react-native-ios-utilities": "5.1.8",
|
||||||
"react-native-mmkv": "2.12.2",
|
"react-native-mmkv": "2.12.2",
|
||||||
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "4.0.2",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 { t } from "i18next";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import type React from "react";
|
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 { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
|
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||||
import { useDownload } from "@/providers/DownloadProvider";
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
@@ -32,6 +33,13 @@ import ProgressCircle from "./ProgressCircle";
|
|||||||
import { RoundButton } from "./RoundButton";
|
import { RoundButton } from "./RoundButton";
|
||||||
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
|
||||||
|
|
||||||
|
export type SelectedOptions = {
|
||||||
|
bitrate: Bitrate;
|
||||||
|
mediaSource: MediaSourceInfo | undefined;
|
||||||
|
audioIndex: number | undefined;
|
||||||
|
subtitleIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface DownloadProps extends ViewProps {
|
interface DownloadProps extends ViewProps {
|
||||||
items: BaseItemDto[];
|
items: BaseItemDto[];
|
||||||
MissingDownloadIconComponent: () => React.ReactElement;
|
MissingDownloadIconComponent: () => React.ReactElement;
|
||||||
@@ -60,18 +68,16 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
useDownload();
|
useDownload();
|
||||||
const downloadedFiles = getDownloadedItems();
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
const [selectedMediaSource, setSelectedMediaSource] = useState<
|
const [selectedOptions, setSelectedOptions] = useState<
|
||||||
MediaSourceInfo | undefined | null
|
SelectedOptions | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const {
|
||||||
useState<number>(0);
|
defaultAudioIndex,
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
|
defaultBitrate,
|
||||||
settings?.defaultBitrate ?? {
|
defaultMediaSource,
|
||||||
key: "Max",
|
defaultSubtitleIndex,
|
||||||
value: undefined,
|
} = useDefaultPlaySettings(items[0], settings);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const userCanDownload = useMemo(
|
const userCanDownload = useMemo(
|
||||||
() => user?.Policy?.EnableContentDownloading,
|
() => user?.Policy?.EnableContentDownloading,
|
||||||
@@ -98,6 +104,24 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
[items, downloadedFiles],
|
[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(() => {
|
const itemsToDownload = useMemo(() => {
|
||||||
if (downloadUnwatchedOnly) {
|
if (downloadUnwatchedOnly) {
|
||||||
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
|
||||||
@@ -153,7 +177,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
!api ||
|
!api ||
|
||||||
!user?.Id ||
|
!user?.Id ||
|
||||||
items.some((p) => !p.Id) ||
|
items.some((p) => !p.Id) ||
|
||||||
(itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
|
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"DownloadItem ~ initiateDownload: No api or user or item",
|
"DownloadItem ~ initiateDownload: No api or user or item",
|
||||||
@@ -164,9 +188,9 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
itemsNotDownloaded.length > 1
|
itemsNotDownloaded.length > 1
|
||||||
? getDefaultPlaySettings(item, settings!)
|
? getDefaultPlaySettings(item, settings!)
|
||||||
: {
|
: {
|
||||||
mediaSource: selectedMediaSource,
|
mediaSource: selectedOptions?.mediaSource,
|
||||||
audioIndex: selectedAudioStream,
|
audioIndex: selectedOptions?.audioIndex,
|
||||||
subtitleIndex: selectedSubtitleStream,
|
subtitleIndex: selectedOptions?.subtitleIndex,
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadDetails = await getDownloadUrl({
|
const downloadDetails = await getDownloadUrl({
|
||||||
@@ -176,7 +200,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
mediaSource: mediaSource!,
|
mediaSource: mediaSource!,
|
||||||
audioStreamIndex: audioIndex ?? -1,
|
audioStreamIndex: audioIndex ?? -1,
|
||||||
subtitleStreamIndex: subtitleIndex ?? -1,
|
subtitleStreamIndex: subtitleIndex ?? -1,
|
||||||
maxBitrate,
|
maxBitrate: selectedOptions?.bitrate || defaultBitrate,
|
||||||
deviceId: api.deviceInfo.id,
|
deviceId: api.deviceInfo.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,18 +229,21 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await startBackgroundDownload(url, item, mediaSource, maxBitrate);
|
await startBackgroundDownload(
|
||||||
|
url,
|
||||||
|
item,
|
||||||
|
mediaSource,
|
||||||
|
selectedOptions?.bitrate || defaultBitrate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
api,
|
api,
|
||||||
user?.Id,
|
user?.Id,
|
||||||
itemsNotDownloaded,
|
itemsNotDownloaded,
|
||||||
selectedMediaSource,
|
selectedOptions,
|
||||||
selectedAudioStream,
|
|
||||||
selectedSubtitleStream,
|
|
||||||
settings,
|
settings,
|
||||||
maxBitrate,
|
defaultBitrate,
|
||||||
startBackgroundDownload,
|
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 = () => {
|
const renderButtonContent = () => {
|
||||||
if (processes.length > 0 && itemsProcesses.length > 0) {
|
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'>
|
<View className='flex flex-col space-y-2 w-full items-start'>
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
inverted
|
inverted
|
||||||
onChange={setMaxBitrate}
|
onChange={(val) =>
|
||||||
selected={maxBitrate}
|
setSelectedOptions(
|
||||||
|
(prev) => prev && { ...prev, bitrate: val },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions?.bitrate}
|
||||||
/>
|
/>
|
||||||
{itemsNotDownloaded.length > 1 && (
|
{itemsNotDownloaded.length > 1 && (
|
||||||
<View className='flex flex-row items-center justify-between w-full py-2'>
|
<View className='flex flex-row items-center justify-between w-full py-2'>
|
||||||
@@ -345,27 +364,51 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{itemsNotDownloaded.length === 1 && (
|
{itemsNotDownloaded.length === 1 && (
|
||||||
<>
|
<View>
|
||||||
<MediaSourceSelector
|
<MediaSourceSelector
|
||||||
item={items[0]}
|
item={items[0]}
|
||||||
onChange={setSelectedMediaSource}
|
onChange={(val) =>
|
||||||
selected={selectedMediaSource}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
mediaSource: val,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selected={selectedOptions?.mediaSource}
|
||||||
/>
|
/>
|
||||||
{selectedMediaSource && (
|
{selectedOptions?.mediaSource && (
|
||||||
<View className='flex flex-col space-y-2'>
|
<View className='flex flex-col space-y-2'>
|
||||||
<AudioTrackSelector
|
<AudioTrackSelector
|
||||||
source={selectedMediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={setSelectedAudioStream}
|
onChange={(val) => {
|
||||||
selected={selectedAudioStream}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
audioIndex: val,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selected={selectedOptions.audioIndex}
|
||||||
/>
|
/>
|
||||||
<SubtitleTrackSelector
|
<SubtitleTrackSelector
|
||||||
source={selectedMediaSource}
|
source={selectedOptions.mediaSource}
|
||||||
onChange={setSelectedSubtitleStream}
|
onChange={(val) => {
|
||||||
selected={selectedSubtitleStream}
|
setSelectedOptions(
|
||||||
|
(prev) =>
|
||||||
|
prev && {
|
||||||
|
...prev,
|
||||||
|
subtitleIndex: val,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
selected={selectedOptions.subtitleIndex}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
@@ -24,36 +24,27 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const isTv = Platform.isTV;
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const commonPrefix = useMemo(() => {
|
const getDisplayName = useCallback((source: MediaSourceInfo) => {
|
||||||
const mediaSources = item.MediaSources || [];
|
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video");
|
||||||
if (!mediaSources.length) return "";
|
if (videoStream?.DisplayTitle) {
|
||||||
|
return videoStream.DisplayTitle;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return commonPrefix;
|
|
||||||
}, [item.MediaSources]);
|
|
||||||
|
|
||||||
const name = (name?: string | null) => {
|
// Fallback to source name
|
||||||
return name?.replace(commonPrefix, "").toLowerCase();
|
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;
|
if (isTv) return null;
|
||||||
|
|
||||||
@@ -93,7 +84,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemTitle>
|
<DropdownMenu.ItemTitle>
|
||||||
{`${name(source.Name)}`}
|
{getDisplayName(source)}
|
||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -181,12 +181,11 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
}
|
}
|
||||||
if (getAudioTracks) {
|
if (getAudioTracks) {
|
||||||
const audioData = await getAudioTracks();
|
const audioData = await getAudioTracks();
|
||||||
|
|
||||||
const allAudio =
|
const allAudio =
|
||||||
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
|
||||||
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
const audioTracks: Track[] = allAudio?.map((audio, idx) => {
|
||||||
if (!mediaSource?.TranscodingUrl) {
|
if (!mediaSource?.TranscodingUrl) {
|
||||||
const vlcIndex = audioData?.at(idx)?.index ?? -1;
|
const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
|
||||||
return {
|
return {
|
||||||
name: audio.DisplayTitle ?? "Undefined Audio",
|
name: audio.DisplayTitle ?? "Undefined Audio",
|
||||||
index: audio.Index ?? -1,
|
index: audio.Index ?? -1,
|
||||||
@@ -201,6 +200,15 @@ export const VideoProvider: React.FC<VideoProviderProps> = ({
|
|||||||
setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
|
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);
|
setAudioTracks(audioTracks);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
"react-native-ios-context-menu": "^3.1.0",
|
"react-native-ios-context-menu": "^3.1.0",
|
||||||
"react-native-ios-utilities": "5.1.8",
|
"react-native-ios-utilities": "5.1.8",
|
||||||
"react-native-mmkv": "2.12.2",
|
"react-native-mmkv": "2.12.2",
|
||||||
|
"react-native-pager-view": "^6.9.1",
|
||||||
"react-native-reanimated": "~3.16.7",
|
"react-native-reanimated": "~3.16.7",
|
||||||
"react-native-reanimated-carousel": "4.0.2",
|
"react-native-reanimated-carousel": "4.0.2",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
|||||||
@@ -3,9 +3,136 @@ import type {
|
|||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
MediaSourceInfo,
|
MediaSourceInfo,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} 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 { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import download from "@/utils/profiles/download";
|
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 ({
|
export const getStreamUrl = async ({
|
||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
@@ -44,6 +171,47 @@ export const getStreamUrl = async ({
|
|||||||
let mediaSource: MediaSourceInfo | undefined;
|
let mediaSource: MediaSourceInfo | undefined;
|
||||||
let sessionId: string | null | 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(
|
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||||
{
|
{
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
@@ -70,46 +238,20 @@ export const getStreamUrl = async ({
|
|||||||
|
|
||||||
sessionId = res.data.PlaySessionId || null;
|
sessionId = res.data.PlaySessionId || null;
|
||||||
mediaSource = res.data.MediaSources?.[0];
|
mediaSource = res.data.MediaSources?.[0];
|
||||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
|
||||||
|
|
||||||
if (transcodeUrl) {
|
const url = getPlaybackUrl(api, item.Id!, mediaSource, {
|
||||||
// We need to change the subtitle method to hls for the transcoded url.
|
subtitleStreamIndex,
|
||||||
if (subtitleStreamIndex === -1) {
|
audioStreamIndex,
|
||||||
transcodeUrl = transcodeUrl.replace(
|
deviceId,
|
||||||
"SubtitleMethod=Encode",
|
startTimeTicks,
|
||||||
"SubtitleMethod=Hls",
|
maxStreamingBitrate,
|
||||||
);
|
userId,
|
||||||
}
|
playSessionId: playSessionId || undefined,
|
||||||
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 directPlayUrl = `${
|
|
||||||
api.basePath
|
|
||||||
}/Videos/${item.Id}/stream?${streamParams.toString()}`;
|
|
||||||
|
|
||||||
console.log("Video is being direct played:", directPlayUrl);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: directPlayUrl,
|
url,
|
||||||
sessionId: sessionId || playSessionId || null,
|
sessionId: sessionId || null,
|
||||||
mediaSource,
|
mediaSource,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -142,9 +284,6 @@ export const getDownloadStreamUrl = async ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaSource: MediaSourceInfo | undefined;
|
|
||||||
let sessionId: string | null | undefined;
|
|
||||||
|
|
||||||
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
const res = await getMediaInfoApi(api).getPlaybackInfo(
|
||||||
{
|
{
|
||||||
itemId: item.Id!,
|
itemId: item.Id!,
|
||||||
@@ -169,53 +308,16 @@ export const getDownloadStreamUrl = async ({
|
|||||||
console.error("Error getting playback info:", res.status, res.statusText);
|
console.error("Error getting playback info:", res.status, res.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionId = res.data.PlaySessionId || null;
|
const sessionId = res.data.PlaySessionId || null;
|
||||||
mediaSource = res.data.MediaSources?.[0];
|
const mediaSource = res.data.MediaSources?.[0];
|
||||||
let transcodeUrl = mediaSource?.TranscodingUrl;
|
|
||||||
|
|
||||||
if (transcodeUrl) {
|
return getDownloadUrl(api, item.Id!, mediaSource, sessionId, {
|
||||||
transcodeUrl = transcodeUrl.replace("master.m3u8", "stream");
|
subtitleStreamIndex,
|
||||||
console.log("Video is being transcoded:", transcodeUrl);
|
audioStreamIndex,
|
||||||
return {
|
deviceId,
|
||||||
url: `${api.basePath}${transcodeUrl}`,
|
startTimeTicks: 0,
|
||||||
sessionId,
|
maxStreamingBitrate,
|
||||||
mediaSource,
|
userId,
|
||||||
};
|
playSessionId: sessionId || undefined,
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user