mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
wip
This commit is contained in:
@@ -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
385
app/(auth)/vlc-player.tsx
Normal 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;
|
||||
}
|
||||
@@ -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={{
|
||||
|
||||
@@ -79,7 +79,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/play-video");
|
||||
router.push("/vlc-player");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
88
components/vlc/VideoDebugInfo.tsx
Normal file
88
components/vlc/VideoDebugInfo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user