Revamped transcoding subtitles

This commit is contained in:
Alex Kim
2024-12-12 02:41:30 +11:00
parent 1d0d99c79b
commit 3fb20a8ca2
8 changed files with 215 additions and 361 deletions

View File

@@ -125,14 +125,7 @@ export default function page() {
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: [
"stream-url",
itemId,
audioIndex,
subtitleIndex,
mediaSourceId,
bitrateValue,
],
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
console.log("Offline:", offline);
if (offline) {
@@ -254,6 +247,7 @@ export default function page() {
videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]);
// TODO: unused should remove.
const reportPlaybackStart = useCallback(async () => {
if (offline) return;
@@ -287,7 +281,12 @@ export default function page() {
if (!item?.Id || !stream) return;
console.log("onProgress ~", currentTimeInTicks, isPlaying);
console.log(
"onProgress ~",
currentTimeInTicks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
@@ -300,7 +299,7 @@ export default function page() {
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped]
[item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
);
useOrientation();

View File

@@ -38,6 +38,7 @@ import Video, {
SelectedTrackType,
VideoRef,
} from "react-native-video";
import index from "../(tabs)/(home)";
const Player = () => {
const api = useAtomValue(apiAtom);
@@ -116,14 +117,7 @@ const Player = () => {
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: [
"stream-url",
itemId,
audioIndex,
subtitleIndex,
bitrateValue,
mediaSourceId,
],
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId],
queryFn: async () => {
if (!api) {
@@ -263,6 +257,13 @@ const Player = () => {
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
console.log(
"onProgress ~",
ticks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
@@ -326,23 +327,39 @@ const Player = () => {
// Set intial Subtitle Track.
// We will only select external tracks if they are are text based. Else it should be burned in already.
const textSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
) || [];
// This function aims to get the embedded track index from the source subtitle index.
const getEmbeddedTrackIndex = (sourceSubtitleIndex: number) => {
const textSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
) || [];
// Get unique text-based subtitles because react-native-video removes hls text tracks duplicates.
const uniqueTextSubs = Array.from(
new Set(textSubs.map((sub) => sub.DisplayTitle))
).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
const matchingSubtitle = textSubs.find(
(sub) => sub?.Index === sourceSubtitleIndex
);
return (
uniqueTextSubs.findIndex(
(sub) => sub?.DisplayTitle === matchingSubtitle?.DisplayTitle
) ?? -1
);
};
const uniqueTextSubs = Array.from(
new Set(textSubs.map((sub) => sub.DisplayTitle))
).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
const chosenSubtitleTrack = textSubs.find(
(sub) => sub.Index === subtitleIndex
);
useEffect(() => {
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
console.log("Setting selected text track", chosenSubtitleTrack);
if (selectedTextTrack === undefined) {
const embeddedTrackIndex = getEmbeddedTrackIndex(subtitleIndex!);
console.log(
"Setting selected text track",
subtitleIndex,
embeddedTrackIndex
);
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);

View File

@@ -128,8 +128,9 @@ export const Controls: React.FC<Props> = ({
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const { bitrateValue } = useLocalSearchParams<{
const { bitrateValue, usedSubtitleIndex } = useLocalSearchParams<{
bitrateValue: string;
subtitleIndex: string;
}>();
const { showSkipButton, skipIntro } = useIntroSkipper(
@@ -153,16 +154,22 @@ export const Controls: React.FC<Props> = ({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
const { mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
const {
mediaSource: newMediaSource,
audioIndex,
subtitleIndex,
} = getDefaultPlaySettings(
previousItem,
settings
settings,
item,
mediaSource ?? undefined
);
const queryParams = new URLSearchParams({
itemId: previousItem.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
mediaSourceId: newMediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue.toString(),
}).toString();

View File

@@ -7,7 +7,7 @@ import { useVideoContext } from "../contexts/VideoContext";
import { EmbeddedSubtitle, ExternalSubtitle } from "../types";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams } from "expo-router";
import { router, useLocalSearchParams } from "expo-router";
interface DropdownViewDirectProps {
showControls: boolean;
@@ -71,13 +71,6 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
bitrateValue: string;
}>();
const [selectedSubtitleIndex, setSelectedSubtitleIndex] = useState<Number>(
parseInt(subtitleIndex)
);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
parseInt(audioIndex)
);
return (
<View
style={{
@@ -116,7 +109,7 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
{allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value={selectedSubtitleIndex === sub.index}
value={subtitleIndex === sub.index.toString()}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
@@ -133,8 +126,9 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
console.log("Set sub index: ", sub.index);
setSubtitleTrack && setSubtitleTrack(sub.index);
}
setSelectedSubtitleIndex(sub.index);
router.setParams({
subtitleIndex: sub.index.toString(),
});
console.log("Subtitle: ", sub);
}}
>
@@ -159,10 +153,12 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
value={audioIndex === track.index.toString()}
onValueChange={() => {
setSelectedAudioIndex(track.index);
setAudioTrack && setAudioTrack(track.index);
router.setParams({
audioIndex: track.index.toString(),
});
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>

View File

@@ -14,10 +14,7 @@ interface DropdownViewProps {
offline?: boolean; // used to disable external subs for downloads
}
const DropdownView: React.FC<DropdownViewProps> = ({
showControls,
offline = false,
}) => {
const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const router = useRouter();
const api = useAtomValue(apiAtom);
const ControlContext = useControlContext();
@@ -46,24 +43,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream);
// This is used in the case where it is transcoding stream.
const chosenSubtitle = textBasedSubs.find(
(x) => x.Index === parseInt(subtitleIndex)
);
let initialSubtitleIndex = -1;
if (!isOnTextSubtitle) {
initialSubtitleIndex = parseInt(subtitleIndex);
} else if (chosenSubtitle) {
initialSubtitleIndex = textBasedSubs.indexOf(chosenSubtitle);
}
const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
useState<number>(initialSubtitleIndex);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<number>(
parseInt(audioIndex)
);
const allSubtitleTracksForTranscodingStream = useMemo(() => {
const disableSubtitle = {
name: "Disable",
@@ -78,38 +57,26 @@ const DropdownView: React.FC<DropdownViewProps> = ({
IsTextSubtitleStream: true,
})) || [];
const imageSubtitles = allSubs
.filter((x) => !x.IsTextSubtitleStream)
.map(
(x) =>
({
name: x.DisplayTitle!,
index: x.Index!,
IsTextSubtitleStream: x.IsTextSubtitleStream,
} as TranscodedSubtitle)
);
console.log("textSubtitles", textSubtitles);
const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s]));
const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s]));
let textIndex = 0; // To track position in textSubtitles
// Merge text and image subtitles in the order of allSubs
const sortedSubtitles = allSubs.map((sub) => {
if (sub.IsTextSubtitleStream) {
if (textSubtitles.length === 0) return disableSubtitle;
const textSubtitle = textSubtitles[textIndex];
textIndex++;
return textSubtitle;
} else {
return {
name: sub.DisplayTitle!,
index: sub.Index!,
IsTextSubtitleStream: sub.IsTextSubtitleStream,
} as TranscodedSubtitle;
}
});
const sortedSubtitles = Array.from(
new Set(
allSubs
.map((sub) => {
const displayTitle = sub.DisplayTitle ?? "";
if (textSubtitlesMap.has(displayTitle)) {
return textSubtitlesMap.get(displayTitle);
}
if (imageSubtitlesMap.has(displayTitle)) {
return imageSubtitlesMap.get(displayTitle);
}
return null;
})
.filter(
(subtitle): subtitle is TranscodedSubtitle => subtitle !== null
)
)
);
console.log("sortedSubtitles", sortedSubtitles);
return [disableSubtitle, ...sortedSubtitles];
}
@@ -145,26 +112,24 @@ const DropdownView: React.FC<DropdownViewProps> = ({
name: x.DisplayTitle!,
index: x.Index!,
})) || [];
const ChangeTranscodingAudio = useCallback(
(audioIndex: number, currentSelectedSubtitleIndex: number) => {
let newSubtitleIndex: number;
if (!isOnTextSubtitle) {
newSubtitleIndex = parseInt(subtitleIndex);
} else if (
currentSelectedSubtitleIndex >= 0 &&
currentSelectedSubtitleIndex < textBasedSubs.length
) {
console.log("setHere SubtitleIndex", currentSelectedSubtitleIndex);
newSubtitleIndex = textBasedSubs[currentSelectedSubtitleIndex].Index!;
console.log("newSubtitleIndex", newSubtitleIndex);
} else {
newSubtitleIndex = -1;
}
// HLS stream indexes are not the same as the actual source indexes.
// This function aims to get the source subtitle index from the embedded track index.
const getSourceSubtitleIndex = (embeddedTrackIndex: number): number => {
// If we're not on text-based subtitles, return the embedded track index
if (!isOnTextSubtitle) {
return parseInt(subtitleIndex);
}
return textBasedSubs[embeddedTrackIndex]?.Index ?? -1;
};
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: newSubtitleIndex?.toString() ?? "",
subtitleIndex: subtitleIndex?.toString() ?? "",
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
@@ -172,7 +137,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
[mediaSource, subtitleIndex, audioIndex]
);
return (
@@ -213,17 +178,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({
{allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.CheckboxItem
value={selectedSubtitleIndex === sub.index}
value={
subtitleIndex ===
(sub.IsTextSubtitleStream && isOnTextSubtitle
? getSourceSubtitleIndex(sub.index).toString()
: sub?.index.toString())
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
console.log("sub", sub);
if (selectedSubtitleIndex === sub?.index) return;
setSelectedSubtitleIndex(sub.index);
if (subtitleIndex === sub?.index.toString()) return;
router.setParams({
subtitleIndex: getSourceSubtitleIndex(
sub.index
).toString(),
});
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
console.log("ChangeTranscodingSubtitle", subtitleIndex);
ChangeTranscodingSubtitle(sub.index);
}}
>
@@ -249,11 +224,14 @@ const DropdownView: React.FC<DropdownViewProps> = ({
{allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (selectedAudioIndex === track.index) return;
setSelectedAudioIndex(track.index);
ChangeTranscodingAudio(track.index, selectedSubtitleIndex);
if (audioIndex === track.index.toString()) return;
console.log("Setting audio track to: ", track.index);
router.setParams({
audioIndex: track.index.toString(),
});
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>

View File

@@ -6,6 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
// Used only for intial play settings.
const useDefaultPlaySettings = (
item: BaseItemDto,
settings: Settings | null

View File

@@ -4,7 +4,8 @@ import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Settings } from "../atoms/settings";
import { Settings, useSettings } from "../atoms/settings";
import { StreamRanker, SubtitleStreamRanker } from "../streamRanker";
interface PlaySettings {
item: BaseItemDto;
@@ -14,9 +15,13 @@ interface PlaySettings {
subtitleIndex?: number | undefined;
}
// Used getting default values for the next player.
export function getDefaultPlaySettings(
item: BaseItemDto,
settings: Settings
settings: Settings,
previousIndex?: number,
previousItem?: BaseItemDto,
previousSource?: MediaSourceInfo
): PlaySettings {
if (item.Type === "Program") {
return {
@@ -41,7 +46,22 @@ export function getDefaultPlaySettings(
(x) => x.Type === "Audio"
)?.Index;
// TODO: Need to most common next subtitle index as an option.
// We prefer the previous track over the default track.
let trackOptions = {};
const mediaStreams = mediaSource?.MediaStreams ?? [];
if (settings?.rememberSubtitleSelections) {
if (previousIndex !== undefined && previousSource) {
const subtitleRanker = new SubtitleStreamRanker();
const ranker = new StreamRanker(subtitleRanker);
ranker.rankStream(
previousIndex,
previousSource,
mediaStreams,
trackOptions
);
}
}
const finalSubtitleIndex = mediaSource?.DefaultAudioStreamIndex;
// 4. Get default bitrate

View File

@@ -1,248 +1,35 @@
interface StreamRankerStrategy {
rankStream(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any,
isSecondarySubtitle: boolean
): void;
}
import {
MediaSourceInfo,
MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
class SubtitleStreamRanker implements StreamRankerStrategy {
rankStream(
abstract class StreamRankerStrategy {
abstract streamType: string;
abstract rankStream(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any,
isSecondarySubtitle: boolean
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void;
protected rank(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void {
if (prevIndex == -1) {
console.debug(`AutoSet Subtitle - No Stream Set`);
if (isSecondarySubtitle) {
trackOptions.DefaultSecondarySubtitleStreamIndex = -1;
} else {
trackOptions.DefaultSubtitleStreamIndex = -1;
}
trackOptions[`Default${this.streamType}StreamIndex`] = -1;
return;
}
if (!prevSource.MediaStreams || !mediaStreams) {
console.debug(`AutoSet Subtitle - No MediaStreams`);
console.debug(`AutoSet ${this.streamType} - No MediaStreams`);
return;
}
this.rank(
prevIndex,
prevSource,
mediaStreams,
trackOptions,
isSecondarySubtitle,
"Subtitle"
);
}
private rank(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any,
isSecondarySubtitle: boolean,
streamType: string
): void {
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
if (!prevStream) {
console.debug(`AutoSet ${streamType} - No prevStream`);
return;
}
console.debug(
`AutoSet ${streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`
);
let prevRelIndex = 0;
for (const stream of prevSource.MediaStreams) {
if (stream.Type != streamType) continue;
if (stream.Index == prevIndex) break;
prevRelIndex += 1;
}
let newRelIndex = 0;
for (const stream of mediaStreams) {
if (stream.Type != streamType) continue;
let score = 0;
if (prevStream.Codec == stream.Codec) score += 1;
if (prevRelIndex == newRelIndex) score += 1;
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle == stream.DisplayTitle
)
score += 2;
if (
prevStream.Language &&
prevStream.Language != "und" &&
prevStream.Language == stream.Language
)
score += 2;
console.debug(
`AutoSet ${streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`
);
if (score > bestStreamScore && score >= 3) {
bestStreamScore = score;
bestStreamIndex = stream.Index;
}
newRelIndex += 1;
}
if (bestStreamIndex != null) {
console.debug(
`AutoSet ${streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`
);
trackOptions.DefaultSubtitleStreamIndex = bestStreamIndex;
} else {
console.debug(
`AutoSet ${streamType} - Threshold not met. Using default.`
);
}
}
}
class AudioStreamRanker implements StreamRankerStrategy {
rankStream(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any
): void {
if (prevIndex == -1) {
console.debug(`AutoSet Audio - No Stream Set`);
return;
}
if (!prevSource.MediaStreams || !mediaStreams) {
console.debug(`AutoSet Audio - No MediaStreams`);
return;
}
this.rank(prevIndex, prevSource, mediaStreams, trackOptions, "Audio");
}
private rank(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any,
streamType: string
): void {
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
if (!prevStream) {
console.debug(`AutoSet ${streamType} - No prevStream`);
return;
}
console.debug(
`AutoSet ${streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`
);
let prevRelIndex = 0;
for (const stream of prevSource.MediaStreams) {
if (stream.Type != streamType) continue;
if (stream.Index == prevIndex) break;
prevRelIndex += 1;
}
let newRelIndex = 0;
for (const stream of mediaStreams) {
if (stream.Type != streamType) continue;
let score = 0;
if (prevStream.Codec == stream.Codec) score += 1;
if (prevRelIndex == newRelIndex) score += 1;
if (
prevStream.DisplayTitle &&
prevStream.DisplayTitle == stream.DisplayTitle
)
score += 2;
if (
prevStream.Language &&
prevStream.Language != "und" &&
prevStream.Language == stream.Language
)
score += 2;
console.debug(
`AutoSet ${streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`
);
if (score > bestStreamScore && score >= 3) {
bestStreamScore = score;
bestStreamIndex = stream.Index;
}
newRelIndex += 1;
}
if (bestStreamIndex != null) {
console.debug(
`AutoSet ${streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`
);
trackOptions.DefaultAudioStreamIndex = bestStreamIndex;
} else {
console.debug(
`AutoSet ${streamType} - Threshold not met. Using default.`
);
}
}
}
abstract class StreamRanker {
private strategy: StreamRankerStrategy;
abstract streamType: string;
constructor(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
rankStream(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any,
streamType: string,
isSecondarySubtitle: boolean
) {
this.strategy.rankStream(
prevIndex,
prevSource,
mediaStreams,
trackOptions,
isSecondarySubtitle
);
}
private rank(
prevIndex: number,
prevSource: any,
mediaStreams: any[],
trackOptions: any
): void {
let bestStreamIndex = null;
let bestStreamScore = 0;
const prevStream = prevSource.MediaStreams[prevIndex];
@@ -308,3 +95,52 @@ abstract class StreamRanker {
}
}
}
class SubtitleStreamRanker extends StreamRankerStrategy {
streamType = "Subtitle";
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void {
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
class AudioStreamRanker extends StreamRankerStrategy {
streamType = "Audio";
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
): void {
super.rank(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
class StreamRanker {
private strategy: StreamRankerStrategy;
constructor(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: StreamRankerStrategy) {
this.strategy = strategy;
}
rankStream(
prevIndex: number,
prevSource: MediaSourceInfo,
mediaStreams: MediaStream[],
trackOptions: any
) {
this.strategy.rankStream(prevIndex, prevSource, mediaStreams, trackOptions);
}
}
export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };