mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Made transcoding content use react-native-video insted
This commit is contained in:
@@ -8,7 +8,16 @@ export default function Layout() {
|
|||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="player"
|
name="direct-player"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
autoHideHomeIndicator: true,
|
||||||
|
title: "",
|
||||||
|
animation: "fade",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="transcoding-player"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
autoHideHomeIndicator: true,
|
autoHideHomeIndicator: true,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { useAtomValue } from "jotai";
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { Alert, Pressable, View } from "react-native";
|
import { Alert, Pressable, View } from "react-native";
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
import { useSharedValue } from "react-native-reanimated";
|
||||||
import transcoding from "@/utils/profiles/transcoding";
|
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const videoRef = useRef<VlcPlayerViewRef>(null);
|
const videoRef = useRef<VlcPlayerViewRef>(null);
|
||||||
@@ -141,7 +140,7 @@ export default function page() {
|
|||||||
maxStreamingBitrate: bitrateValue,
|
maxStreamingBitrate: bitrateValue,
|
||||||
mediaSourceId: mediaSourceId,
|
mediaSourceId: mediaSourceId,
|
||||||
subtitleStreamIndex: subtitleIndex,
|
subtitleStreamIndex: subtitleIndex,
|
||||||
deviceProfile: !bitrateValue ? native : transcoding,
|
deviceProfile: native,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res) return null;
|
if (!res) return null;
|
||||||
@@ -374,18 +373,8 @@ export default function page() {
|
|||||||
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chosenAudioTrack) throw new Error("No audio track found");
|
if (!chosenAudioTrack) throw new Error("No audio track found");
|
||||||
|
|
||||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||||
} else {
|
|
||||||
// Transcoded playback CASE
|
|
||||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
|
||||||
externalTrack = {
|
|
||||||
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
|
|
||||||
DeliveryUrl: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
546
app/(auth)/player/transcoding-player.tsx
Normal file
546
app/(auth)/player/transcoding-player.tsx
Normal 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;
|
||||||
@@ -65,9 +65,18 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const colorChangeProgress = useSharedValue(0);
|
const colorChangeProgress = useSharedValue(0);
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// console.log(bitrateValue);
|
||||||
const goToPlayer = useCallback(
|
const goToPlayer = useCallback(
|
||||||
(q: string) => {
|
(q: string, bitrateValue: number | undefined) => {
|
||||||
router.push(`/player/player?${q}`);
|
if (!bitrateValue) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.push(`/player/direct-player?${q}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// @ts-expect-error
|
||||||
|
router.push(`/player/transcoding-player?${q}`);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
);
|
);
|
||||||
@@ -86,7 +95,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
const queryString = queryParams.toString();
|
const queryString = queryParams.toString();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
goToPlayer(queryString);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +216,7 @@ export const PlayButton: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
goToPlayer(queryString);
|
goToPlayer(queryString, selectedOptions.bitrate?.value);
|
||||||
break;
|
break;
|
||||||
case cancelButtonIndex:
|
case cancelButtonIndex:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -145,8 +145,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrate.value) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
}, [previousItem, settings]);
|
}, [previousItem, settings]);
|
||||||
|
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
@@ -163,8 +168,13 @@ export const Controls: React.FC<Props> = ({
|
|||||||
bitrateValue: bitrate.toString(),
|
bitrateValue: bitrate.toString(),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
|
if (!bitrate.value) {
|
||||||
|
// @ts-expect-error
|
||||||
|
router.replace(`player/direct-player?${queryParams}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/player?${queryParams}`);
|
router.replace(`player/transcoding-player?${queryParams}`);
|
||||||
}, [nextItem, settings]);
|
}, [nextItem, settings]);
|
||||||
|
|
||||||
const updateTimes = useCallback(
|
const updateTimes = useCallback(
|
||||||
|
|||||||
@@ -61,16 +61,9 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
)[];
|
)[];
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
}, [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.
|
// Only used for transcoding streams.
|
||||||
const {
|
const {
|
||||||
subtitleIndex: subtitleIndexStr,
|
subtitleIndex,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
bitrateValue,
|
bitrateValue,
|
||||||
} = useLocalSearchParams<{
|
} = useLocalSearchParams<{
|
||||||
@@ -84,11 +77,12 @@ 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.
|
// Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
|
||||||
const isOnTextSubtitle =
|
const isOnTextSubtitle =
|
||||||
mediaSource?.MediaStreams?.find(
|
mediaSource?.MediaStreams?.find(
|
||||||
(x) => x.Index === parseInt(subtitleIndexStr) && x.IsTextSubtitleStream
|
(x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
|
||||||
) || subtitleIndexStr === "-1";
|
) || subtitleIndex === "-1";
|
||||||
|
|
||||||
// TODO: Add support for text sorting subtitles renaming.
|
// Need to sort in the right order when on text mode. So its seamless.
|
||||||
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
const allSubtitleTracksForTranscodingStream = useMemo(() => {
|
||||||
|
const disableSubtitle = { name: 'Disable', index: -1, IsTextSubtitleStream: true } as TranscodedSubtitle;
|
||||||
const allSubs =
|
const allSubs =
|
||||||
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
|
||||||
if (isOnTextSubtitle) {
|
if (isOnTextSubtitle) {
|
||||||
@@ -109,7 +103,11 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
IsTextSubtitleStream: x.IsTextSubtitleStream,
|
IsTextSubtitleStream: x.IsTextSubtitleStream,
|
||||||
} as TranscodedSubtitle)
|
} as TranscodedSubtitle)
|
||||||
);
|
);
|
||||||
return [...textSubtitles, ...imageSubtitles];
|
return [
|
||||||
|
disableSubtitle,
|
||||||
|
...textSubtitles,
|
||||||
|
...imageSubtitles
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
|
||||||
@@ -119,11 +117,12 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: 'Disable', index: -1, IsTextSubtitleStream: true } as TranscodedSubtitle,
|
disableSubtitle,
|
||||||
...transcodedSubtitle
|
...transcodedSubtitle
|
||||||
];
|
];
|
||||||
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
}, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
|
||||||
|
|
||||||
|
|
||||||
const ChangeTranscodingSubtitle = useCallback(
|
const ChangeTranscodingSubtitle = useCallback(
|
||||||
(subtitleIndex: number) => {
|
(subtitleIndex: number) => {
|
||||||
const queryParams = new URLSearchParams({
|
const queryParams = new URLSearchParams({
|
||||||
@@ -135,7 +134,29 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
router.replace(`player/player?${queryParams}`);
|
router.replace(`player/transcoding?${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?${queryParams}`);
|
||||||
},
|
},
|
||||||
[mediaSource]
|
[mediaSource]
|
||||||
);
|
);
|
||||||
@@ -211,7 +232,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={`subtitle-item-${idx}`}
|
key={`subtitle-item-${idx}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (subtitleIndexStr === sub.index.toString()) return;
|
if (subtitleIndex === sub.index.toString()) return;
|
||||||
|
|
||||||
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
|
||||||
setSubtitleTrack && setSubtitleTrack(sub.index);
|
setSubtitleTrack && setSubtitleTrack(sub.index);
|
||||||
@@ -242,7 +263,7 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
loop={true}
|
loop={true}
|
||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
{audioTracks?.map((track, idx: number) => (
|
{!mediaSource?.TranscodingUrl && audioTracks?.map((track, idx: number) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={`audio-item-${idx}`}
|
key={`audio-item-${idx}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
@@ -255,6 +276,21 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
|
|||||||
</DropdownMenu.ItemTitle>
|
</DropdownMenu.ItemTitle>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{mediaSource?.TranscodingUrl && allAudio?.map((track, idx: number) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={`audio-item-${idx}`}
|
||||||
|
onSelect={() => {
|
||||||
|
if (audioIndex === track.index.toString()) return;
|
||||||
|
ChangeTranscodingAudio(track.index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key={`audio-item-title-${idx}`}>
|
||||||
|
{track.name}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
</DropdownMenu.SubContent>
|
</DropdownMenu.SubContent>
|
||||||
</DropdownMenu.Sub>
|
</DropdownMenu.Sub>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
private val onVideoLoadEnd by EventDispatcher()
|
private val onVideoLoadEnd by EventDispatcher()
|
||||||
|
|
||||||
private var startPosition: Int? = 0
|
private var startPosition: Int? = 0
|
||||||
private var isTranscodedStream: Boolean = false
|
|
||||||
private var isMediaReady: Boolean = false
|
private var isMediaReady: Boolean = false
|
||||||
private var externalTrack: Map<String, String>? = null
|
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
|
val uri = source["uri"] as? String
|
||||||
if (uri != null && uri.contains("m3u8")) {
|
|
||||||
isTranscodedStream = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle video load start event
|
// Handle video load start event
|
||||||
// onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
|
// 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() {
|
private fun updateVideoProgress() {
|
||||||
val player = mediaPlayer ?: return
|
val player = mediaPlayer ?: return
|
||||||
@@ -257,25 +243,16 @@ class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context
|
|||||||
val durationMs = player.media?.duration?.toInt() ?: 0
|
val durationMs = player.media?.duration?.toInt() ?: 0
|
||||||
|
|
||||||
if (currentTimeMs >= 0 && currentTimeMs < durationMs) {
|
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) {
|
if (player.isPlaying && !isMediaReady) {
|
||||||
isMediaReady = true
|
isMediaReady = true
|
||||||
externalTrack?.let {
|
externalTrack?.let {
|
||||||
val name = it["name"]
|
val name = it["name"]
|
||||||
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
val deliveryUrl = it["DeliveryUrl"] ?: ""
|
||||||
if (!name.isNullOrEmpty()) {
|
if (!name.isNullOrEmpty() && !deliveryUrl.isNullOrEmpty()) {
|
||||||
if (!isTranscodedStream) {
|
setSubtitleURL(deliveryUrl, name)
|
||||||
setSubtitleURL(deliveryUrl, name)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
setSubtitleTrackByName(name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTranscodedStream && startPosition != 0) {
|
|
||||||
seekTo((startPosition ?: 0) * 1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
onVideoProgress(mapOf(
|
onVideoProgress(mapOf(
|
||||||
"currentTime" to currentTimeMs,
|
"currentTime" to currentTimeMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user