Merge branch 'feature/vlc-support-android' of https://github.com/Alexk2309/streamyfin into pr/226

This commit is contained in:
Fredrik Burmester
2024-11-24 19:35:40 +01:00
8 changed files with 713 additions and 114 deletions

View File

@@ -8,7 +8,16 @@ export default function Layout() {
<SystemBars hidden />
<Stack>
<Stack.Screen
name="player"
name="direct-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="transcoding-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,

View File

@@ -35,7 +35,6 @@ import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Pressable, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import transcoding from "@/utils/profiles/transcoding";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
@@ -141,7 +140,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: !bitrateValue ? native : transcoding,
deviceProfile: native,
});
if (!res) return null;

View File

@@ -0,0 +1,546 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { TrackInfo } from "@/modules/vlc-player";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
import { Controls } from "@/components/video-player/controls/Controls";
import transcoding from "@/utils/profiles/transcoding";
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const firstTime = useRef(true);
const dimensions = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!itemId) {
console.warn("No itemId");
return null;
}
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: [
"stream-url",
itemId,
audioIndex,
subtitleIndex,
bitrateValue,
user,
mediaSourceId,
],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!item) {
console.warn("No item", itemId, item);
return null;
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: transcoding,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.warn("No sessionId or mediaSource or url", url);
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!item,
staleTime: 0,
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
}
},
[
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]
);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
};
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = secondsToTicks(data.currentTime);
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
// 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);
if (!item?.Id || data.currentTime === 0) {
return;
}
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
isSeeking,
stream,
mediaSourceId,
audioIndex,
subtitleIndex,
]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
// 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) || [];
const chosenSubtitleTrack = textSubs.find(
(sub) => sub.Index === subtitleIndex
);
useEffect(() => {
if (chosenSubtitleTrack && selectedTextTrack === undefined) {
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: textSubs.indexOf(chosenSubtitleTrack),
});
}
}, [embededTextTracks]);
const getAudioTracks = (): TrackInfo[] => {
return audioTracks.map((t) => ({
name: t.name,
index: t.index,
}));
};
const getSubtitleTracks = (): TrackInfo[] => {
return embededTextTracks.map((t) => ({
name: t.title ?? "",
index: t.index,
language: t.language,
}));
};
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View
style={{
flex: 1,
position: "relative",
height: "100%",
width: "100%",
}}
>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
style={{
flex: 1,
height: "100%",
width: "100%",
position: "absolute",
top: 0,
left: 0,
zIndex: 0,
}}
>
{videoSource ? (
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
console.log("onAudioTracks: ", e.audioTracks);
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
) : (
<Text>No video source...</Text>
)}
</Pressable>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) =>
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
})
}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
console.log("setAudioTrack ~", i);
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

View File

@@ -65,9 +65,18 @@ export const PlayButton: React.FC<Props> = ({
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
// console.log(bitrateValue);
const goToPlayer = useCallback(
(q: string) => {
router.push(`/player/player?${q}`);
(q: string, bitrateValue: number | undefined) => {
if (!bitrateValue) {
// @ts-expect-error
router.push(`/player/direct-player?${q}`);
return;
}
// @ts-expect-error
router.push(`/player/transcoding-player?${q}`);
},
[router]
);
@@ -86,7 +95,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString();
if (!client) {
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
@@ -207,7 +216,7 @@ export const PlayButton: React.FC<Props> = ({
});
break;
case 1:
goToPlayer(queryString);
goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;

View File

@@ -145,8 +145,13 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrate.toString(),
}).toString();
if (!bitrate.value) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/player?${queryParams}`);
router.replace(`player/transcoding-player?${queryParams}`);
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
@@ -163,8 +168,13 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrate.toString(),
}).toString();
if (!bitrate.value) {
// @ts-expect-error
router.replace(`player/direct-player?${queryParams}`);
return;
}
// @ts-expect-error
router.replace(`player/player?${queryParams}`);
router.replace(`player/transcoding-player?${queryParams}`);
}, [nextItem, settings]);
const updateTimes = useCallback(

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as DropdownMenu from "zeego/dropdown-menu";
@@ -12,6 +12,8 @@ import {
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useLocalSearchParams, useRouter } from "expo-router";
import { parse } from "@babel/core";
import { set } from "lodash";
interface DropdownViewProps {
showControls: boolean;
@@ -61,19 +63,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
)[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
// const audioForTranscodingStream = mediaSource?.MediaStreams?.filter(
// (x) => x.Type === "Audio"
// ).map((x) => ({
// name: x.DisplayTitle!,
// index: x.Index!,
// }));
// Only used for transcoding streams.
const {
subtitleIndex: subtitleIndexStr,
audioIndex,
bitrateValue,
} = useLocalSearchParams<{
const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
@@ -84,13 +74,41 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
const isOnTextSubtitle =
mediaSource?.MediaStreams?.find(
(x) => x.Index === parseInt(subtitleIndexStr) && x.IsTextSubtitleStream
) || subtitleIndexStr === "-1";
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
) || subtitleIndex === "-1";
// TODO: Add support for text sorting subtitles renaming.
const allSubs =
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)
);
const intialSubtitleIndex =
!bitrateValue || !isOnTextSubtitle
? parseInt(subtitleIndex)
: chosenSubtitle && isOnTextSubtitle
? textBasedSubs.indexOf(chosenSubtitle)
: -1;
const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
useState<Number>(intialSubtitleIndex);
const [selectedAudioIndex, setSelectedAudioIndex] = useState<Number>(
parseInt(audioIndex)
);
// TODO: Need to account for the fact when user is on text-based subtitle at start.
// Then the user swaps to another text based subtitle.
// Then changes audio track.
// The user will have the first text based subtitle selected still but it should be the second text based subtitle.
const allSubtitleTracksForTranscodingStream = useMemo(() => {
const allSubs =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
const disableSubtitle = {
name: "Disable",
index: -1,
IsTextSubtitleStream: true,
} as TranscodedSubtitle;
if (isOnTextSubtitle) {
const textSubtitles =
subtitleTracks?.map((s) => ({
@@ -109,7 +127,27 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
IsTextSubtitleStream: x.IsTextSubtitleStream,
} as TranscodedSubtitle)
);
return [...textSubtitles, ...imageSubtitles];
const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s]));
const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s]));
const sortedSubtitles = 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
);
return [disableSubtitle, ...sortedSubtitles];
}
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
@@ -118,10 +156,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
IsTextSubtitleStream: x.IsTextSubtitleStream!,
}));
return [
{ name: 'Disable', index: -1, IsTextSubtitleStream: true } as TranscodedSubtitle,
...transcodedSubtitle
];
return [disableSubtitle, ...transcodedSubtitle];
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
const ChangeTranscodingSubtitle = useCallback(
@@ -135,7 +170,29 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
}).toString();
// @ts-expect-error
router.replace(`player/player?${queryParams}`);
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
// Audio tracks for transcoding streams.
const allAudio =
mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
name: x.DisplayTitle!,
index: x.Index!,
})) || [];
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
subtitleIndex: subtitleIndex,
mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
bitrateValue: bitrateValue,
}).toString();
// @ts-expect-error
router.replace(`player/transcoding-player?${queryParams}`);
},
[mediaSource]
);
@@ -177,9 +234,10 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
>
{!mediaSource?.TranscodingUrl &&
allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
<DropdownMenu.Item
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
onSelect={() => {
value={selectedSubtitleIndex === sub.index}
onValueChange={() => {
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(
@@ -196,37 +254,38 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
setSubtitleTrack && setSubtitleTrack(sub.index);
}
setSelectedSubtitleIndex(sub.index);
console.log("Subtitle: ", sub);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.CheckboxItem>
))}
{mediaSource?.TranscodingUrl &&
allSubtitleTracksForTranscodingStream?.map(
(sub, idx: number) => (
<DropdownMenu.Item
<DropdownMenu.CheckboxItem
value={selectedSubtitleIndex === sub.index}
key={`subtitle-item-${idx}`}
onSelect={() => {
if (subtitleIndexStr === sub.index.toString()) return;
onValueChange={() => {
if (subtitleIndex === sub?.index.toString()) return;
setSelectedSubtitleIndex(sub.index);
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
setSubtitleTrack && setSubtitleTrack(sub.index);
return;
}
ChangeTranscodingSubtitle(sub.index);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle
key={`subtitle-item-title-${idx}`}
>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.CheckboxItem>
)
)}
</DropdownMenu.SubContent>
@@ -242,19 +301,37 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
loop={true}
sideOffset={10}
>
{audioTracks?.map((track, idx: number) => (
<DropdownMenu.Item
key={`audio-item-${idx}`}
onSelect={() => {
setAudioTrack && setAudioTrack(track.index);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
{!mediaSource?.TranscodingUrl &&
audioTracks?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
onValueChange={() => {
setSelectedAudioIndex(track.index);
setAudioTrack && setAudioTrack(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
{mediaSource?.TranscodingUrl &&
allAudio?.map((track, idx: number) => (
<DropdownMenu.CheckboxItem
key={`audio-item-${idx}`}
value={selectedAudioIndex === track.index}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
setSelectedAudioIndex(track.index);
ChangeTranscodingAudio(track.index);
}}
>
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
{track.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>

View File

@@ -30,7 +30,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
private val onVideoLoadEnd by EventDispatcher()
private var startPosition: Int? = 0
private var isTranscodedStream: Boolean = false
private var isMediaReady: Boolean = false
private var externalTrack: Map<String, String>? = null
@@ -60,9 +59,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val uri = source["uri"] as? String
if (uri != null && uri.contains("m3u8")) {
isTranscodedStream = true
}
// Handle video load start event
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
@@ -239,16 +235,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
}
}
// Only used for HLS transcoded streams
private fun setSubtitleTrackByName(trackName: String) {
val track = mediaPlayer?.getSpuTracks()?.firstOrNull { it.name.startsWith(trackName) }
val trackIndex = track?.id ?: -1
println("Track Index setting to: $trackIndex")
if (trackIndex != -1) {
setSubtitleTrack(trackIndex)
}
}
private fun updateVideoProgress() {
val player = mediaPlayer ?: return
@@ -257,25 +243,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
val durationMs = player.media?.duration?.toInt() ?: 0
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
// Handle when VLC starts at cloest earliest segment skip to the start time, for transcoded streams.
// Set subtitle URL if available
if (player.isPlaying && !isMediaReady) {
isMediaReady = true
externalTrack?.let {
val name = it["name"]
val deliveryUrl = it["DeliveryUrl"] ?: ""
if (!name.isNullOrEmpty()) {
if (!isTranscodedStream) {
setSubtitleURL(deliveryUrl, name)
}
else {
setSubtitleTrackByName(name)
}
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
setSubtitleURL(deliveryUrl, name)
}
}
if (isTranscodedStream && startPosition != 0) {
seekTo((startPosition ?: 0) * 1000)
}
}
onVideoProgress(mapOf(
"currentTime" to currentTimeMs,

View File

@@ -12,7 +12,6 @@ class VlcPlayerView: ExpoView {
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var isTranscodedStream: Bool = false
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
@@ -112,9 +111,6 @@ class VlcPlayerView: ExpoView {
initOptions.append("--start-time=\(startPosition)")
let uri = source["uri"] as? String
if let uri = uri, uri.contains("m3u8") {
self.isTranscodedStream = true
}
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
@@ -587,7 +583,7 @@ extension VlcPlayerView: VLCMediaPlayerDelegate {
// Playing and not transcoding, we can let it in no HLS issue.
// We should also mark it as playing when the media is ready.
// Fix HLS issue.
if player.isPlaying && (!self.isTranscodedStream || self.isMediaReady) {
if player.isPlaying && self.isMediaReady {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
@@ -648,39 +644,15 @@ extension VlcPlayerView: VLCMediaPlayerDelegate {
let durationMs = player.media?.length.intValue ?? 0
if currentTimeMs >= 0 && currentTimeMs < durationMs {
// Handle when VLC starts at cloest earliest segment skip to the start time, for transcoded streams.
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"] as? String, !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] as? String ?? ""
if !self.isTranscodedStream {
self.setSubtitleURL(deliveryUrl, name: name)
} else {
self.setSubtitleTrackByName(name)
}
self.setSubtitleURL(deliveryUrl, name: name)
}
}
// HLS bug.
if self.isTranscodedStream {
if self.startPosition > 0 {
print("Seeking to start position: \(self.startPosition)")
self.seekTo(self.startPosition * 1000)
} else {
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
"isPlaying": true,
"isBuffering": false,
"state": "Playing",
]
self.onVideoStateChange?(stateInfo)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,