This commit is contained in:
Fredrik Burmester
2024-10-12 12:55:45 +02:00
parent 091a8ff6c3
commit bf8687a473
12 changed files with 1141 additions and 301 deletions

View File

@@ -1,53 +1,393 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
import { TAB_HEIGHT } from "@/constants/Values";
import { VlcPlayerView } from "@/modules/vlc-player";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import React, { useEffect, useRef, useState } from "react";
import { Button, ScrollView, TouchableOpacity, View } from "react-native";
BaseItemDto,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserLibraryApi,
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import NetInfo from "@react-native-community/netinfo";
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Platform,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScrollingCollectionListSection = {
type: "ScrollingCollectionList";
title?: string;
queryKey: (string | undefined | null)[];
queryFn: QueryFunction<BaseItemDto[]>;
orientation?: "horizontal" | "vertical";
};
type MediaListSection = {
type: "MediaListSection";
queryKey: (string | undefined)[];
queryFn: QueryFunction<BaseItemDto>;
};
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
const insets = useSafeAreaInsets();
const videoRef = useRef<VlcPlayerViewRef>(null);
const [playbackState, setPlaybackState] = useState<
PlaybackStatePayload["nativeEvent"] | null
>(null);
const [progress, setProgress] = useState<
ProgressUpdatePayload["nativeEvent"] | null
>(null);
const queryClient = useQueryClient();
const router = useRouter();
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles } = useDownload();
const navigation = useNavigation();
useEffect(() => {
videoRef.current?.play();
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
headerLeft: () => (
<TouchableOpacity
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather
name="download"
color={hasDownloads ? Colors.primary : "white"}
size={22}
/>
</TouchableOpacity>
),
});
}, [downloadedFiles, navigation, router]);
const checkConnection = useCallback(async () => {
setLoadingRetry(true);
const state = await NetInfo.fetch();
setIsConnected(state.isConnected);
setLoadingRetry(false);
}, []);
const onProgress = (event: ProgressUpdatePayload) => {
const { currentTime, duration } = event.nativeEvent;
console.log(`Current Time: ${currentTime}, Duration: ${duration}`);
setProgress(event.nativeEvent);
};
const onPlaybackStateChanged = (event: PlaybackStatePayload) => {
const { isBuffering, currentTime, duration, target, type } =
event.nativeEvent;
console.log("onVideoStateChange", {
isBuffering,
currentTime,
duration,
target,
type,
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected == false || state.isInternetReachable === false)
setIsConnected(false);
else setIsConnected(true);
});
setPlaybackState(event.nativeEvent);
};
NetInfo.fetch().then((state) => {
setIsConnected(state.isConnected);
});
return () => {
unsubscribe();
};
}, []);
const {
data: userViews,
isError: e1,
isLoading: l1,
} = useQuery({
queryKey: ["home", "userViews", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
});
return response.data.Items || null;
},
enabled: !!api && !!user?.Id,
staleTime: 60 * 1000,
});
const {
data: mediaListCollections,
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
const collections = useMemo(() => {
const allow = ["movies", "tvshows"];
return (
userViews?.filter(
(c) => c.CollectionType && allow.includes(c.CollectionType)
) || []
);
}, [userViews]);
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["home"],
refetchType: "all",
type: "all",
exact: false,
});
await queryClient.invalidateQueries({
queryKey: ["item"],
refetchType: "all",
type: "all",
exact: false,
});
setLoading(false);
}, [queryClient]);
const createCollectionConfig = useCallback(
(
title: string,
queryKey: string[],
includeItemTypes: BaseItemKind[],
parentId: string | undefined
): ScrollingCollectionListSection => ({
title,
queryKey,
queryFn: async () => {
if (!api) return [];
return (
(
await getUserLibraryApi(api).getLatestMedia({
userId: user?.Id,
limit: 50,
fields: ["PrimaryImageAspectRatio", "Path"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes,
parentId,
})
).data || []
);
},
type: "ScrollingCollectionList",
}),
[api, user?.Id]
);
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: "Continue Watching",
queryKey: ["home", "resumeItems", user.Id],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
includeItemTypes: ["Movie", "Series", "Episode"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: "Next Up",
queryKey: ["home", "nextUp-all", user?.Id],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
enableResumable: false,
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...latestMediaViews,
...(mediaListCollections?.map(
(ml) =>
({
title: ml.Name,
queryKey: ["home", "mediaList", ml.Id!],
queryFn: async () => ml,
type: "MediaListSection",
orientation: "vertical",
} as Section)
) || []),
{
title: "Suggested Movies",
queryKey: ["home", "suggestedMovies", user?.Id],
queryFn: async () =>
(
await getSuggestionsApi(api).getSuggestions({
userId: user?.Id,
limit: 10,
mediaType: ["Video"],
type: ["Movie"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "vertical",
},
{
title: "Suggested Episodes",
queryKey: ["home", "suggestedEpisodes", user?.Id],
queryFn: async () => {
try {
const suggestions = await getSuggestions(api, user.Id);
const nextUpPromises = suggestions.map((series) =>
getNextUp(api, user.Id, series.Id)
);
const nextUpResults = await Promise.all(nextUpPromises);
return nextUpResults.filter((item) => item !== null) || [];
} catch (error) {
console.error("Error fetching data:", error);
return [];
}
},
type: "ScrollingCollectionList",
orientation: "horizontal",
},
];
return ss;
}, [api, user?.Id, collections, mediaListCollections]);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
<Button
color="black"
onPress={() => {
checkConnection();
}}
justify="center"
className="mt-2"
iconRight={
loadingRetry ? null : (
<Ionicons name="refresh" size={20} color="white" />
)
}
>
{loadingRetry ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
"Retry"
)}
</Button>
</View>
</View>
);
}
const insets = useSafeAreaInsets();
if (e1 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (l1 || l2)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
@@ -59,113 +399,58 @@ export default function index() {
}}
>
<View className="flex flex-col space-y-4">
<VlcPlayerView
ref={videoRef}
source={{
uri: "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
autoplay: true,
isNetwork: true,
}}
style={{ width: "100%", height: 300 }}
onVideoProgress={onProgress}
progressUpdateInterval={2000}
onVideoStateChange={onPlaybackStateChanged}
/>
<VideoDebugInfo
playbackState={playbackState}
progress={progress}
playerRef={videoRef}
/>
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
<Button
title="pause"
onPress={() => {
videoRef.current?.pause();
}}
/>
<Button
title="play"
onPress={() => {
videoRef.current?.play();
}}
/>
<Button
title="seek to 10 seconds"
onPress={() => {
videoRef.current?.seekTo(10);
}}
/>
</ScrollView>
);
}
const VideoDebugInfo: React.FC<{
playbackState: PlaybackStatePayload["nativeEvent"] | null;
progress: ProgressUpdatePayload["nativeEvent"] | null;
playerRef: React.RefObject<VlcPlayerViewRef>;
}> = ({ playbackState, progress, playerRef }) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
// Function to get suggestions
async function getSuggestions(api: Api, userId: string | undefined) {
if (!userId) return [];
const response = await getSuggestionsApi(api).getSuggestions({
userId,
limit: 10,
mediaType: ["Unknown"],
type: ["Series"],
});
return response.data.Items ?? [];
}
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
fetchTracks();
}, [playerRef]);
return (
<View className="p-2.5 bg-black mt-2.5">
<Text className="font-bold">Playback State:</Text>
{playbackState && (
<>
<Text>Type: {playbackState.type}</Text>
<Text>Current Time: {playbackState.currentTime}</Text>
<Text>Duration: {playbackState.duration}</Text>
<Text>Is Buffering: {playbackState.isBuffering ? "Yes" : "No"}</Text>
<Text>Target: {playbackState.target}</Text>
</>
)}
<Text className="font-bold mt-2.5">Progress:</Text>
{progress && (
<>
<Text>Current Time: {progress.currentTime}</Text>
<Text>Duration: {progress.duration.toFixed(2)}</Text>
</>
)}
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
{audioTracks &&
audioTracks.map((track) => (
<Text key={track.index}>
{track.name} (Index: {track.index})
</Text>
))}
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
{subtitleTracks &&
subtitleTracks.map((track) => (
<Text key={track.index}>
{track.name} (Index: {track.index})
</Text>
))}
<TouchableOpacity
className="mt-2.5 bg-blue-500 p-2 rounded"
onPress={() => {
if (playerRef.current) {
playerRef.current.getAudioTracks().then(setAudioTracks);
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
}
}}
>
<Text className="text-white text-center">Refresh Tracks</Text>
</TouchableOpacity>
</View>
);
};
// Function to get the next up TV show for a series
async function getNextUp(
api: Api,
userId: string | undefined,
seriesId: string | undefined
) {
if (!userId || !seriesId) return null;
const response = await getTvShowsApi(api).getNextUp({
userId,
seriesId,
limit: 1,
});
return response.data.Items?.[0] ?? null;
}

385
app/(auth)/vlc-player.tsx Normal file
View File

@@ -0,0 +1,385 @@
import { Controls } from "@/components/video-player/Controls";
import { VideoDebugInfo } from "@/components/vlc/VideoDebugInfo";
import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
PlaybackType,
usePlaySettings,
} from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { ticksToSeconds } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import { set } from "lodash";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Alert, Dimensions, Pressable, StatusBar, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrackType,
VideoRef,
} from "react-native-video";
export default function page() {
const { playSettings, playUrl, playSessionId } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
const videoRef = useRef<VlcPlayerViewRef>(null);
const poster = usePoster(playSettings, api);
const videoSource = useVideoSource(playSettings, api, poster, playUrl);
const firstTime = useRef(true);
const screenDimensions = Dimensions.get("screen");
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 [playbackState, setPlaybackState] = useState<
PlaybackStatePayload["nativeEvent"] | null
>(null);
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
} else {
videoRef.current?.play();
await getPlaystateApi(api).onPlaybackProgress({
itemId: playSettings.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
}
},
[isPlaying, api, playSettings?.item?.Id, videoRef, settings]
);
const play = useCallback(() => {
videoRef.current?.play();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaybackStopped(true);
videoRef.current?.stop();
reportPlaybackStopped();
}, [videoRef]);
const reportPlaybackStopped = async () => {
await getPlaystateApi(api).onPlaybackStopped({
itemId: playSettings?.item?.Id!,
mediaSourceId: playSettings.mediaSource?.Id!,
positionTicks: Math.floor(progress.value),
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const reportPlaybackStart = async () => {
await getPlaystateApi(api).onPlaybackStart({
itemId: playSettings?.item?.Id!,
audioStreamIndex: playSettings.audioIndex
? playSettings.audioIndex
: undefined,
subtitleStreamIndex: playSettings.subtitleIndex
? playSettings.subtitleIndex
: undefined,
mediaSourceId: playSettings.mediaSource?.Id!,
playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: playSessionId ? playSessionId : undefined,
});
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const { currentTime, duration } = data.nativeEvent;
console.log("onProgress ~", currentTime);
progress.value = currentTime;
// cacheProgress.value = secondsToTicks(data.playableDuration);
// setIsBuffering(data.playableDuration === 0);
// if (!playSettings?.item?.Id || data.currentTime === 0) return;
// await getPlaystateApi(api).onPlaybackProgress({
// itemId: playSettings.item.Id,
// audioStreamIndex: playSettings.audioIndex
// ? playSettings.audioIndex
// : undefined,
// subtitleStreamIndex: playSettings.subtitleIndex
// ? playSettings.subtitleIndex
// : undefined,
// mediaSourceId: playSettings.mediaSource?.Id!,
// positionTicks: Math.round(ticks),
// isPaused: !isPlaying,
// playMethod: playUrl.includes("m3u8") ? "Transcode" : "DirectStream",
// playSessionId: playSessionId ? playSessionId : undefined,
// });
},
[playSettings?.item.Id, isPlaying, api, isPlaybackStopped]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
const { orientation } = useOrientation();
useOrientationSettings();
useAndroidNavigationBar();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const selectedSubtitleTrack = useMemo(() => {
const a = playSettings?.mediaSource?.MediaStreams?.find(
(s) => s.Index === playSettings.subtitleIndex
);
console.log(a);
return a;
}, [playSettings]);
const [hlsSubTracks, setHlsSubTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const selectedTextTrack = useMemo(() => {
for (let st of hlsSubTracks) {
if (st.title === selectedSubtitleTrack?.DisplayTitle) {
return {
type: SelectedTrackType.TITLE,
value: selectedSubtitleTrack?.DisplayTitle ?? "",
};
}
}
return undefined;
}, [hlsSubTracks]);
const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
const { target, state, isBuffering, isPlaying } = e.nativeEvent;
console.log("onPlaybackStateChanged", {
target,
state,
isBuffering,
isPlaying,
});
if (state === "Playing") {
setIsPlaying(true);
return;
}
if (state === "Paused") {
setIsPlaying(false);
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
setPlaybackState(e.nativeEvent);
};
useEffect(() => {
return () => {
stop();
};
}, []);
return (
<View
style={{
width: screenDimensions.width,
height: screenDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<VlcPlayerView
ref={videoRef}
source={{
uri: playUrl,
autoplay: true,
isNetwork: true,
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
/>
</Pressable>
<VideoDebugInfo
style={{
position: "absolute",
top: 0,
left: 0,
zIndex: 10,
}}
playbackState={playbackState}
progress={{
currentTime: progress.value,
duration: 0,
}}
playerRef={videoRef}
/>
<Controls
item={playSettings.item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
/>
</View>
);
}
export function usePoster(
playSettings: PlaybackType | null,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!playSettings?.item || !api) return undefined;
return playSettings.item.Type === "Audio"
? `${api.basePath}/Items/${playSettings.item.AlbumId}/Images/Primary?tag=${playSettings.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: playSettings.item,
quality: 70,
width: 200,
});
}, [playSettings?.item, api]);
return poster ?? undefined;
}
export function useVideoSource(
playSettings: PlaybackType | null,
api: Api | null,
poster: string | undefined,
playUrl?: string | null
) {
const videoSource = useMemo(() => {
if (!playSettings || !api || !playUrl) {
return null;
}
const startPosition = playSettings.item?.UserData?.PlaybackPositionTicks
? Math.round(
ticksToSeconds(playSettings.item.UserData.PlaybackPositionTicks)
)
: 0;
return {
uri: playUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: playSettings.item?.AlbumArtist ?? undefined,
title: playSettings.item?.Name || "Unknown",
description: playSettings.item?.Overview ?? undefined,
imageUri: poster,
subtitle: playSettings.item?.Album ?? undefined,
},
};
}, [playSettings, api, poster]);
return videoSource;
}

View File

@@ -345,6 +345,15 @@ function Layout() {
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/vlc-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-offline-video"
options={{

View File

@@ -79,7 +79,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
return;
}
router.push("/play-video");
router.push("/vlc-player");
return;
}

View File

@@ -6,7 +6,7 @@ import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { formatTimeString, secondsToMs, ticksToMs } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
@@ -32,10 +32,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { VlcPlayerViewRef } from "@/modules/vlc-player/src/VlcPlayer.types";
import { secondsToTicks } from "@/utils/secondsToTicks";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VideoRef | null>;
videoRef: React.MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
@@ -122,6 +124,7 @@ export const Controls: React.FC<Props> = ({
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const { showSkipButton, skipIntro } = useIntroSkipper(
item.Id,
@@ -171,8 +174,8 @@ export const Controls: React.FC<Props> = ({
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - currentProgress);
const current = currentProgress;
const remaining = maxValue - currentProgress;
setCurrentTime(current);
setRemainingTime(remaining);
@@ -202,18 +205,19 @@ export const Controls: React.FC<Props> = ({
useEffect(() => {
if (item) {
progress.value = item?.UserData?.PlaybackPositionTicks || 0;
max.value = item.RunTimeTicks || 0;
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
max.value = ticksToMs(item.RunTimeTicks || 0);
}
}, [item]);
const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = useCallback((value: number) => {
progress.value = value;
const handleSliderComplete = useCallback(async (value: number) => {
isSeeking.value = false;
videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
if (wasPlayingRef.current === true) videoRef.current?.resume();
progress.value = value;
await videoRef.current?.seekTo(Math.max(0, Math.floor(value)));
if (wasPlayingRef.current === true) videoRef.current?.play();
}, []);
const handleSliderChange = (value: number) => {
@@ -222,7 +226,10 @@ export const Controls: React.FC<Props> = ({
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
videoRef.current?.pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
@@ -232,12 +239,12 @@ export const Controls: React.FC<Props> = ({
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
const curr = progress.value;
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
setTimeout(() => {
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
await videoRef.current?.seekTo(
Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
);
if (wasPlayingRef.current === true) videoRef.current?.play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
@@ -245,16 +252,15 @@ export const Controls: React.FC<Props> = ({
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
console.log("handleSkipForward");
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = await videoRef.current?.getCurrentPosition();
const curr = progress.value;
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
setTimeout(() => {
if (wasPlayingRef.current === true) videoRef.current?.resume();
}, 10);
const newTime = curr + secondsToMs(settings.forwardSkipTime);
console.log("handleSkipForward", newTime);
await videoRef.current?.seekTo(Math.max(0, newTime));
if (wasPlayingRef.current === true) videoRef.current?.play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
@@ -376,7 +382,8 @@ export const Controls: React.FC<Props> = ({
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
onPress={async () => {
await videoRef.current?.stop();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"

View File

@@ -0,0 +1,88 @@
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
TrackInfo,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { useState, useEffect } from "react";
import { View, TouchableOpacity, ViewProps } from "react-native";
import { Text } from "../common/Text";
import React from "react";
interface Props extends ViewProps {
playbackState: PlaybackStatePayload["nativeEvent"] | null;
progress: ProgressUpdatePayload["nativeEvent"] | null;
playerRef: React.RefObject<VlcPlayerViewRef>;
}
export const VideoDebugInfo: React.FC<Props> = ({
playbackState,
progress,
playerRef,
...props
}) => {
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
useEffect(() => {
const fetchTracks = async () => {
if (playerRef.current) {
const audio = await playerRef.current.getAudioTracks();
const subtitles = await playerRef.current.getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
fetchTracks();
}, [playerRef]);
return (
<View className="p-2.5 bg-black mt-2.5" {...props}>
<Text className="font-bold">Playback State:</Text>
{playbackState && (
<>
<Text>Type: {playbackState.type}</Text>
<Text>Current Time: {playbackState.currentTime}</Text>
<Text>Duration: {playbackState.duration}</Text>
<Text>Is Buffering: {playbackState.isBuffering ? "Yes" : "No"}</Text>
<Text>Target: {playbackState.target}</Text>
</>
)}
<Text className="font-bold mt-2.5">Progress:</Text>
{progress && (
<>
<Text>Current Time: {progress.currentTime}</Text>
<Text>Duration: {progress.duration.toFixed(2)}</Text>
</>
)}
<Text className="font-bold mt-2.5">Audio Tracks:</Text>
{audioTracks &&
audioTracks.map((track) => (
<Text key={track.index}>
{track.name} (Index: {track.index})
</Text>
))}
<Text className="font-bold mt-2.5">Subtitle Tracks:</Text>
{subtitleTracks &&
subtitleTracks.map((track) => (
<Text key={track.index}>
{track.name} (Index: {track.index})
</Text>
))}
<TouchableOpacity
className="mt-2.5 bg-blue-500 p-2 rounded"
onPress={() => {
if (playerRef.current) {
playerRef.current.getAudioTracks().then(setAudioTracks);
playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
}
}}
>
<Text className="text-white text-center">Refresh Tracks</Text>
</TouchableOpacity>
</View>
);
};

View File

@@ -8,10 +8,6 @@ public class VlcPlayerModule: Module {
view.setSource(source)
}
Prop("progressUpdateInterval") { (view: VlcPlayerView, interval: Double) in
view.setProgressUpdateInterval(interval)
}
Prop("paused") { (view: VlcPlayerView, paused: Bool) in
if paused {
view.pause()
@@ -33,7 +29,6 @@ public class VlcPlayerModule: Module {
}
Events(
"onProgress",
"onPlaybackStateChanged",
"onVideoLoadStart",
"onVideoStateChange",
@@ -48,18 +43,14 @@ public class VlcPlayerModule: Module {
view.pause()
}
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Double) in
AsyncFunction("stop") { (view: VlcPlayerView) in
view.stop()
}
AsyncFunction("seekTo") { (view: VlcPlayerView, time: Int32) in
view.seekTo(time)
}
AsyncFunction("jumpBackward") { (view: VlcPlayerView, interval: Int) in
view.jumpBackward(interval)
}
AsyncFunction("jumpForward") { (view: VlcPlayerView, interval: Int) in
view.jumpForward(interval)
}
AsyncFunction("setAudioTrack") { (view: VlcPlayerView, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}

View File

@@ -5,11 +5,9 @@ import UIKit
class VlcPlayerView: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var progressUpdateTimer: Timer?
private var progressUpdateInterval: TimeInterval = 0.5
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var stateUpdateTimer: Timer?
private var lastReportedState: VLCMediaPlayerState?
// MARK: - Initialization
@@ -43,7 +41,8 @@ class VlcPlayerView: ExpoView {
}
private func setupMediaPlayer() {
DispatchQueue.main.async {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer = VLCMediaPlayer()
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
@@ -62,21 +61,31 @@ class VlcPlayerView: ExpoView {
// MARK: - Public Methods
@objc func play() {
self.mediaPlayer?.play()
self.isPaused = false
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.play()
self.isPaused = false
}
}
@objc func pause() {
self.mediaPlayer?.pause()
self.isPaused = true
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.pause()
self.isPaused = true
}
}
@objc func seekTo(_ time: Double) {
self.mediaPlayer?.time = VLCTime(int: Int32(time * 1000))
@objc func seekTo(_ time: Int32) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.time = VLCTime(int: time)
}
}
@objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.mediaPlayer?.stop()
self.mediaPlayer = nil
@@ -111,8 +120,7 @@ class VlcPlayerView: ExpoView {
media.addOptions(mediaOptions)
}
// Parse the media asynchronously
media.parse()
// Set the media without parsing
self.mediaPlayer?.media = media
if autoplay {
@@ -123,25 +131,16 @@ class VlcPlayerView: ExpoView {
}
}
@objc func setProgressUpdateInterval(_ interval: Double) {
progressUpdateInterval = TimeInterval(interval / 1000.0)
updateProgressTimer()
}
@objc func jumpBackward(_ interval: Int) {
mediaPlayer?.jumpBackward(Int32(interval))
}
@objc func jumpForward(_ interval: Int) {
mediaPlayer?.jumpForward(Int32(interval))
}
@objc func setMuted(_ muted: Bool) {
mediaPlayer?.audio?.isMuted = muted
DispatchQueue.main.async {
self.mediaPlayer?.audio?.isMuted = muted
}
}
@objc func setVolume(_ volume: Int) {
mediaPlayer?.audio?.volume = Int32(volume)
DispatchQueue.main.async {
self.mediaPlayer?.audio?.volume = Int32(volume)
}
}
@objc func setVideoAspectRatio(_ ratio: String) {
@@ -170,24 +169,109 @@ class VlcPlayerView: ExpoView {
}
}
// @objc func getAudioTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// guard let trackNames = mediaPlayer.audioTrackNames,
// let trackIndexes = mediaPlayer.audioTrackIndexes
// else {
// DispatchQueue.main.async {
// reject("ERROR", "No audio tracks available", nil)
// }
// return
// }
// let tracks = zip(trackNames, trackIndexes).map { name, index in
// return ["name": name, "index": index]
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
@objc func setSubtitleTrack(_ trackIndex: Int) {
DispatchQueue.main.async {
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
if trackIndex == -1 {
// Disable subtitles
self.mediaPlayer?.currentVideoSubTitleIndex = -1
} else {
// Set the subtitle track
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
}
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.videoSubTitlesNames,
let trackIndexes = mediaPlayer?.videoSubTitlesIndexes
else {
guard let mediaPlayer = self.mediaPlayer else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
let count = mediaPlayer.numberOfSubtitlesTracks
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
// Add the "Disabled" track
tracks.append(["name": "Disabled", "index": -1])
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
tracks.append(["name": name, "index": index.intValue])
}
}
return tracks
}
// @objc func getSubtitleTracks(
// _ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock
// ) {
// DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// guard let self = self, let mediaPlayer = self.mediaPlayer else {
// DispatchQueue.main.async {
// reject("ERROR", "Media player not available", nil)
// }
// return
// }
// let count = mediaPlayer.numberOfSubtitlesTracks
// guard count > 0 else {
// DispatchQueue.main.async {
// reject("ERROR", "No subtitle tracks available", nil)
// }
// return
// }
// var tracks: [[String: Any]] = [["name": "Disabled", "index": -1]]
// if let names = mediaPlayer.videoSubTitlesNames as? [String],
// let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
// {
// for (index, name) in zip(indexes, names) {
// tracks.append(["name": name, "index": index.intValue])
// }
// }
// DispatchQueue.main.async {
// resolve(tracks)
// }
// }
// }
@objc func setSubtitleDelay(_ delay: Int) {
DispatchQueue.main.async {
self.mediaPlayer?.currentVideoSubTitleDelay = NSInteger(delay)
@@ -271,30 +355,26 @@ class VlcPlayerView: ExpoView {
}
}
@objc func stop() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Stop and release the media player
self.mediaPlayer?.stop()
self.mediaPlayer?.delegate = nil
self.mediaPlayer = nil
// Clear the video view
self.videoView?.removeFromSuperview()
self.videoView = nil
// Remove notifications
NotificationCenter.default.removeObserver(self)
}
}
// MARK: - Private Methods
private func updateProgressTimer() {
progressUpdateTimer?.invalidate()
progressUpdateTimer = Timer.scheduledTimer(
withTimeInterval: progressUpdateInterval, repeats: true
) { [weak self] _ in
self?.sendProgressUpdate()
}
}
private func sendProgressUpdate() {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
let currentTime = player.time.intValue
let duration = player.media?.length.intValue ?? 0
let progress: [String: Any] = [
"currentTime": currentTime,
"duration": duration,
]
self.onVideoProgress?(progress)
}
}
@objc private func applicationWillResignActive() {
if !isPaused {
pause()
@@ -317,7 +397,6 @@ class VlcPlayerView: ExpoView {
// MARK: - Expo Events
@objc var onProgress: RCTDirectEventBlock?
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@@ -326,92 +405,77 @@ class VlcPlayerView: ExpoView {
// MARK: - Deinitialization
deinit {
stateUpdateTimer?.invalidate()
release()
}
}
extension VlcPlayerView: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
guard let player = self.mediaPlayer else { return }
let currentState = player.state
// If the state hasn't changed, don't do anything
guard currentState != lastReportedState else { return }
// Cancel any pending state update
stateUpdateTimer?.invalidate()
// Schedule a new state update
stateUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) {
[weak self] _ in
self?.reportStateChange(currentState)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.updatePlayerState()
}
}
private func reportStateChange(_ state: VLCMediaPlayerState) {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
private func updatePlayerState() {
DispatchQueue.main.async { [weak self] in
guard let self = self, let player = self.mediaPlayer else { return }
let currentState = player.state
print("VLC Player State Changed: \(currentState.description)")
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"isPlaying": currentState == .playing,
"isBuffering": currentState == .buffering,
]
switch state {
switch currentState {
case .opening:
stateInfo["type"] = "Opening"
case .paused:
self.isPaused = true
stateInfo["type"] = "Paused"
case .stopped:
stateInfo["type"] = "Stopped"
stateInfo["state"] = "Opening"
case .buffering:
if player.isPlaying {
self.isPaused = false
stateInfo["type"] = "Playing"
} else {
stateInfo["type"] = "Buffering"
stateInfo["isBuffering"] = true
}
stateInfo["state"] = "Buffering"
stateInfo["isBuffering"] = true
case .playing:
self.isPaused = false
stateInfo["type"] = "Playing"
case .esAdded:
stateInfo["type"] = "ESAdded"
stateInfo["state"] = "Playing"
case .paused:
stateInfo["state"] = "Paused"
case .stopped:
stateInfo["state"] = "Stopped"
case .ended:
print("VLCMediaPlayerStateEnded")
stateInfo["type"] = "Ended"
stateInfo["state"] = "Ended"
case .error:
stateInfo["type"] = "Error"
self.release()
@unknown default:
stateInfo["type"] = "Unknown"
stateInfo["state"] = "Error"
default:
stateInfo["state"] = "Unknown"
}
self.lastReportedState = state
self.onVideoStateChange?(stateInfo)
if currentState != self.lastReportedState {
self.lastReportedState = currentState
self.onVideoStateChange?(stateInfo)
}
}
}
func mediaPlayerTimeChanged(_ aNotification: Notification) {
updateVideoProgress()
DispatchQueue.main.async { [weak self] in
self?.updateVideoProgress()
}
}
private func updateVideoProgress() {
DispatchQueue.main.async {
guard let player = self.mediaPlayer else { return }
let currentTime = player.time.intValue
let duration = player.media?.length.intValue ?? 0
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
if currentTime >= 0 && currentTime < duration {
if currentTimeMs >= 0 && currentTimeMs < durationMs {
self.onVideoProgress?([
"target": self.reactTag ?? NSNull(),
"currentTime": currentTime,
"duration": duration,
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
}

View File

@@ -1,19 +1,19 @@
export type PlaybackStatePayload = {
nativeEvent: {
target: number;
type:
state:
| "Opening"
| "Paused"
| "Stopped"
| "Buffering"
| "Playing"
| "ESAdded"
| "Paused"
| "Stopped"
| "Ended"
| "Error"
| "Unknown";
currentTime: number;
duration: number;
isBuffering?: boolean;
isBuffering: boolean;
isPlaying: boolean;
};
};
@@ -70,9 +70,8 @@ export type VlcPlayerViewProps = {
export interface VlcPlayerViewRef {
play: () => Promise<void>;
pause: () => Promise<void>;
stop: () => Promise<void>;
seekTo: (time: number) => Promise<void>;
jumpBackward: (interval: number) => Promise<void>;
jumpForward: (interval: number) => Promise<void>;
setAudioTrack: (trackIndex: number) => Promise<void>;
getAudioTracks: () => Promise<TrackInfo[] | null>;
setSubtitleTrack: (trackIndex: number) => Promise<void>;

View File

@@ -31,15 +31,12 @@ const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(
pause: async () => {
await nativeRef.current?.pause();
},
stop: async () => {
await nativeRef.current?.stop();
},
seekTo: async (time: number) => {
await nativeRef.current?.seekTo(time);
},
jumpBackward: async (interval: number) => {
await nativeRef.current?.jumpBackward(interval);
},
jumpForward: async (interval: number) => {
await nativeRef.current?.jumpForward(interval);
},
setAudioTrack: async (trackIndex: number) => {
await nativeRef.current?.setAudioTrack(trackIndex);
},

View File

@@ -97,7 +97,6 @@ export const getStreamUrl = async ({
breakOnNonKeyFrames: false,
copyTimestamps: false,
enableMpegtsM2TsMode: false,
},
}
);
@@ -111,16 +110,16 @@ export const getStreamUrl = async ({
console.log("getStreamUrl ~ ", item.MediaType);
if (item.MediaType === "Video") {
if (mediaSource?.TranscodingUrl) {
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
return {
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
url: `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`,
sessionId: sessionId,
};
}
if (mediaSource?.SupportsDirectPlay || forceDirectPlay === true) {
if (mediaSource?.TranscodingUrl) {
return {
url: `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource?.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`,
url: `${api.basePath}${mediaSource.TranscodingUrl}`,
sessionId: sessionId,
};
}

View File

@@ -35,13 +35,14 @@ export const runtimeTicksToSeconds = (
else return `${minutes}m ${seconds}s`;
};
// t: ms
export const formatTimeString = (
t: number | null | undefined,
tick = false
): string => {
if (t === null || t === undefined) return "0:00";
let seconds = t;
let seconds = t / 1000;
if (tick) {
seconds = Math.floor(t / 10000000); // Convert ticks to seconds
}
@@ -66,5 +67,20 @@ export const secondsToTicks = (seconds?: number | undefined) => {
export const ticksToSeconds = (ticks?: number | undefined) => {
if (!ticks) return 0;
return ticks / 10000000;
return Math.floor(ticks / 10000000);
};
export const msToTicks = (ms?: number | undefined) => {
if (!ms) return 0;
return ms * 10000;
};
export const ticksToMs = (ticks?: number | undefined) => {
if (!ticks) return 0;
return Math.floor(ticks / 10000);
};
export const secondsToMs = (seconds?: number | undefined) => {
if (!seconds) return 0;
return seconds * 1000;
};