This commit is contained in:
Fredrik Burmester
2024-11-10 23:29:21 +01:00
parent 865fbdf834
commit 1fdf7ca42f
15 changed files with 123 additions and 1806 deletions

4
.gitignore vendored
View File

@@ -26,7 +26,7 @@ package-lock.json
/ios
/android
modules/vlc-player/android
modules/player/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
@@ -35,4 +35,4 @@ credentials.json
.continuerc.json
# Ignore VLC player android folder
modules/vlc-player/android
modules/player/android

View File

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

View File

@@ -0,0 +1,40 @@
import { Stack } from "expo-router";
import React from "react";
import { SystemBars } from "react-native-edge-to-edge";
export default function Layout() {
return (
<>
<SystemBars hidden />
<Stack>
<Stack.Screen
name="player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="offline-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="music-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
</Stack>
</>
);
}

View File

@@ -313,8 +313,6 @@ export default function page() {
}}
className="flex flex-col items-center justify-center"
>
<StatusBar hidden />
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}

View File

@@ -0,0 +1,2 @@
// @ts-ignore
export { default } from "@/components/video-player/offline-player";

View File

@@ -0,0 +1,2 @@
// @ts-ignore
export { default } from "@/components/video-player/player";

View File

@@ -1,411 +0,0 @@
import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/Controls";
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, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { writeToLog } from "@/utils/log";
import native from "@/utils/profiles/native";
import { msToTicks, ticksToMs } from "@/utils/time";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Pressable,
StatusBar,
useWindowDimensions,
View,
} from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
export default function page() {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const windowDimensions = useWindowDimensions();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: BITRATES[0].value;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) return;
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId && !!api,
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: [
"stream-url",
itemId,
audioIndex,
subtitleIndex,
mediaSourceId,
bitrateValue,
],
queryFn: async () => {
if (!api) return;
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) return null;
console.log(url);
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!api && !!item,
staleTime: 0,
});
const togglePlay = useCallback(
async (ms: number) => {
if (!api || !stream) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
await videoRef.current?.pause();
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(ms),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
console.log("ACtually marked as paused");
} else {
videoRef.current?.play();
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(ms),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
},
[
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
]
);
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 () => {
const currentTimeInTicks = msToTicks(progress.value);
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!,
mediaSourceId: mediaSourceId,
positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!,
});
};
const reportPlaybackStart = async () => {
if (!api || !stream) return;
await getPlaystateApi(api).onPlaybackStart({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
});
};
const onProgress = useCallback(
async (data: ProgressUpdatePayload) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
if (!item?.Id || !api || !stream) return;
const { currentTime } = data.nativeEvent;
if (isBuffering) {
setIsBuffering(false);
}
progress.value = currentTime;
const currentTimeInTicks = msToTicks(currentTime);
await getPlaystateApi(api).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
},
[item?.Id, isPlaying, api, isPlaybackStopped]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
const onPlaybackStateChanged = (e: PlaybackStatePayload) => {
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
return;
}
if (state === "Paused") {
setIsPlaying(false);
return;
}
if (isPlaying) {
setIsPlaying(true);
setIsBuffering(false);
} else if (isBuffering) {
setIsBuffering(true);
}
};
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
if (!stream || !item) return null;
const startPosition = item?.UserData?.PlaybackPositionTicks
? ticksToMs(item.UserData.PlaybackPositionTicks)
: 0;
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full"
>
<VlcPlayerView
ref={videoRef}
source={{
uri: stream.url,
autoplay: true,
isNetwork: true,
startPosition,
initOptions: ["--sub-text-scale=60"],
}}
style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress}
progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged}
onVideoLoadStart={() => {}}
onVideoLoadEnd={() => {
setIsVideoLoaded(true);
}}
onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent);
Alert.alert(
"Error",
"An error occurred while playing the video. Check logs in settings."
);
writeToLog("ERROR", "Video Error", e.nativeEvent);
}}
/>
</Pressable>
{videoRef.current && (
<Controls
mediaSource={stream.mediaSource}
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded}
play={videoRef.current?.play}
pause={videoRef.current?.pause}
seek={videoRef.current?.seekTo}
enableTrickplay={true}
getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={false}
setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={videoRef.current.setAudioTrack}
stop={stop}
isVlc
/>
)}
</View>
);
}
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -329,45 +329,8 @@ function Layout() {
name="(auth)/player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/vlc-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/offline-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/offline-vlc-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/music-player"
options={{
headerShown: false,
autoHideHomeIndicator: true,
title: "",
animation: "fade",
header: () => null,
}}
/>
<Stack.Screen

View File

@@ -88,15 +88,12 @@ export const PlayButton: React.FC<Props> = ({
},
});
// const directStream = useMemo(() => {
// if (!url || url.length === 0) return "Loading...";
// if (url.includes("m3u8")) return "Transcoded stream";
// return "Direct stream";
// }, [url]);
// const item = useMemo(() => {
// return playSettings?.item;
// }, [playSettings?.item]);
const goToPlayer = useCallback(
(q: string) => {
router.push(`/player/player?${q}`);
},
[router]
);
const onPress = useCallback(async () => {
if (!item) return;
@@ -112,11 +109,7 @@ export const PlayButton: React.FC<Props> = ({
const queryString = queryParams.toString();
if (!client) {
if (Platform.OS === "ios") {
router.push(`/vlc-player?${queryString}`);
} else {
router.push(`/player?${queryString}`);
}
goToPlayer(queryString);
return;
}
@@ -237,9 +230,7 @@ export const PlayButton: React.FC<Props> = ({
});
break;
case 1:
if (Platform.OS === "ios")
router.push(`/vlc-player?${queryString}`);
else router.push(`/player?${queryString}`);
goToPlayer(queryString);
break;
case cancelButtonIndex:
break;

View File

@@ -41,7 +41,10 @@ import {
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
@@ -164,11 +167,7 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrate.toString(),
}).toString();
if (Platform.OS === "ios") {
router.replace(`/vlc-player?${queryParams}`);
} else {
router.replace(`/player?${queryParams}`);
}
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
@@ -193,11 +192,7 @@ export const Controls: React.FC<Props> = ({
bitrateValue: bitrate.toString(),
}).toString();
if (Platform.OS === "ios") {
router.replace(`/vlc-player?${queryParams}`);
} else {
router.replace(`/player?${queryParams}`);
}
}, [nextItem, settings]);
const updateTimes = useCallback(
@@ -398,22 +393,19 @@ export const Controls: React.FC<Props> = ({
return (
<View
style={[
{
style={{
flex: 1,
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
},
]}
width: "100%",
height: "100%",
}}
>
{/* <VideoDebugInfo playerRef={videoRef} /> */}
<View
style={{
position: "absolute",
top: insets.top,
left: insets.left,
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
@@ -510,8 +502,7 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
bottom: insets.bottom + 97,
right: insets.right,
bottom: 97,
},
]}
className={`z-10 p-4
@@ -529,8 +520,7 @@ export const Controls: React.FC<Props> = ({
<View
style={{
position: "absolute",
bottom: insets.bottom + 94,
right: insets.right,
bottom: 94,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
@@ -557,8 +547,8 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width + 100,
height: windowDimensions.height + 100,
width: "100%",
height: "100%",
opacity: showControls ? 1 : 0,
},
]}
@@ -571,8 +561,8 @@ export const Controls: React.FC<Props> = ({
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
width: "100%",
height: "100%",
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
@@ -586,8 +576,8 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
top: insets.top,
right: insets.right,
top: 0,
right: 0,
opacity: showControls ? 1 : 0,
},
]}
@@ -621,9 +611,9 @@ export const Controls: React.FC<Props> = ({
style={[
{
position: "absolute",
width: windowDimensions.width - insets.left - insets.right,
maxHeight: windowDimensions.height,
left: insets.left,
width: "100%",
maxHeight: "100%",
left: 0,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
opacity: showControls ? 1 : 0,
},

View File

@@ -1,733 +0,0 @@
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { writeToLog } from "@/utils/log";
import { formatTimeString, secondsToMs, ticksToMs } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
type MediaStream,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Dimensions,
Platform,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { useAtomValue } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
interface Props {
item: BaseItemDto;
videoRef: React.MutableRefObject<VlcPlayerViewRef | null>;
isPlaying: boolean;
isSeeking: SharedValue<boolean>;
cacheProgress: SharedValue<number>;
progress: SharedValue<number>;
isBuffering: boolean;
showControls: boolean;
ignoreSafeAreas?: boolean;
setIgnoreSafeAreas: React.Dispatch<React.SetStateAction<boolean>>;
enableTrickplay?: boolean;
togglePlay: (ticks: number) => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
isVideoLoaded?: boolean;
mediaSource?: MediaSourceInfo | null;
seek: (ticks: number) => void;
play: (() => Promise<void>) | (() => void);
pause: () => void;
getAudioTracks?: () => Promise<TrackInfo[] | null>;
getSubtitleTracks?: () => Promise<TrackInfo[] | null>;
setSubtitleURL?: (url: string) => void;
setSubtitleTrack?: (index: number) => void;
setAudioTrack?: (index: number) => void;
stop?: (() => Promise<void>) | (() => void);
}
export const VlcControls: React.FC<Props> = ({
item,
seek,
play,
pause,
togglePlay,
isPlaying,
isSeeking,
progress,
isBuffering,
cacheProgress,
showControls,
setShowControls,
ignoreSafeAreas,
setIgnoreSafeAreas,
mediaSource,
isVideoLoaded,
getAudioTracks,
getSubtitleTracks,
setSubtitleURL,
setSubtitleTrack,
setAudioTrack,
stop,
offline = false,
}) => {
const [settings] = useSettings();
const router = useRouter();
const insets = useSafeAreaInsets();
const { setPlaySettings, playSettings } = usePlaySettings();
const api = useAtomValue(apiAtom);
const windowDimensions = Dimensions.get("window");
const { previousItem, nextItem } = useAdjacentItems({ item });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
item,
!offline
);
const [currentTime, setCurrentTime] = useState(0); // Seconds
const [remainingTime, setRemainingTime] = useState(0); // Seconds
const min = useSharedValue(0);
const max = useSharedValue(item.RunTimeTicks || 0);
const wasPlayingRef = useRef(false);
const lastProgressRef = useRef<number>(0);
const { showSkipButton, skipIntro } = useIntroSkipper(
offline ? undefined : item.Id,
currentTime,
seek,
play
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
offline ? undefined : item.Id,
currentTime,
seek,
play
);
const goToPreviousItem = useCallback(() => {
if (!previousItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(previousItem, settings);
setPlaySettings({
item: previousItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
if (Platform.OS === "ios") router.replace("/vlc-player");
else router.replace("/player");
}, [previousItem, settings]);
const goToNextItem = useCallback(() => {
if (!nextItem || !settings) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(nextItem, settings);
setPlaySettings({
item: nextItem,
bitrate,
mediaSource,
audioIndex,
subtitleIndex,
});
if (Platform.OS === "ios") router.replace("/vlc-player");
else router.replace("/player");
}, [nextItem, settings]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = currentProgress;
const remaining = maxValue - currentProgress;
setCurrentTime(current);
setRemainingTime(remaining);
if (currentProgress === maxValue) {
setShowControls(true);
// Automatically play the next item if it exists
goToNextItem();
}
},
[goToNextItem]
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
if (item) {
progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks);
max.value = ticksToMs(item.RunTimeTicks || 0);
}
}, [item]);
const toggleControls = () => setShowControls(!showControls);
const handleSliderComplete = useCallback(async (value: number) => {
isSeeking.value = false;
progress.value = value;
await seek(Math.max(0, Math.floor(value)));
if (wasPlayingRef.current === true) play();
}, []);
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
wasPlayingRef.current = isPlaying;
lastProgressRef.current = progress.value;
pause();
isSeeking.value = true;
}, [showControls, isPlaying]);
const handleSkipBackward = useCallback(async () => {
if (!settings?.rewindSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
await seek(Math.max(0, curr - secondsToMs(settings.rewindSkipTime)));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings, isPlaying]);
const handleSkipForward = useCallback(async () => {
if (!settings?.forwardSkipTime) return;
wasPlayingRef.current = isPlaying;
try {
const curr = progress.value;
if (curr !== undefined) {
const newTime = curr + secondsToMs(settings.forwardSkipTime);
await seek(Math.max(0, newTime));
if (wasPlayingRef.current === true) play();
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings, isPlaying]);
const toggleIgnoreSafeAreas = useCallback(() => {
setIgnoreSafeAreas((prev) => !prev);
}, []);
const [audioTracks, setAudioTracks] = useState<TrackInfo[] | null>(null);
const [subtitleTracks, setSubtitleTracks] = useState<TrackInfo[] | null>(
null
);
useEffect(() => {
const fetchTracks = async () => {
if (getAudioTracks && getSubtitleTracks) {
const audio = await getAudioTracks();
const subtitles = await getSubtitleTracks();
setAudioTracks(audio);
setSubtitleTracks(subtitles);
}
};
fetchTracks();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
type EmbeddedSubtitle = {
name: string;
index: number;
isExternal: false;
};
type ExternalSubtitle = {
name: string;
index: number;
isExternal: true;
deliveryUrl: string;
};
const allSubtitleTracks = useMemo(() => {
const embeddedSubs =
subtitleTracks?.map((s) => ({
name: s.name,
index: s.index,
isExternal: false,
deliveryUrl: undefined,
})) || [];
const externalSubs =
mediaSource?.MediaStreams?.filter(
(stream) => stream.Type === "Subtitle" && stream.IsExternal
).map((s) => ({
name: s.DisplayTitle!,
index: s.Index!,
isExternal: true,
deliveryUrl: s.DeliveryUrl,
})) || [];
// Create a Set of embedded subtitle names for quick lookup
const embeddedSubNames = new Set(embeddedSubs.map((sub) => sub.name));
// Filter out external subs that have the same name as embedded subs
const uniqueExternalSubs = externalSubs.filter(
(sub) => !embeddedSubNames.has(sub.name)
);
// Combine embedded and unique external subs
return [...embeddedSubs, ...uniqueExternalSubs] as (
| EmbeddedSubtitle
| ExternalSubtitle
)[];
}, [item, isVideoLoaded, subtitleTracks, mediaSource]);
return (
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
},
]}
>
{/* <VideoDebugInfo playerRef={videoRef} /> */}
{setSubtitleURL && setSubtitleTrack && setAudioTrack && (
<View
style={{
position: "absolute",
top: insets.top,
left: insets.left,
zIndex: 1000,
opacity: showControls ? 1 : 0,
}}
className="p-4"
pointerEvents={showControls ? "auto" : "none"}
>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2">
<Ionicons
name="ellipsis-horizontal"
size={24}
color={"white"}
/>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Subtitle
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{/* <DropdownMenu.CheckboxItem
key="none-item"
value="off"
onValueChange={() => {
videoRef.current?.setSubtitleTrack(-1);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key={`none-item-title`}>
None
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> */}
{allSubtitleTracks.length > 0
? allSubtitleTracks?.map((sub, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value="off"
onValueChange={() => {
if (sub.isExternal) {
setSubtitleURL(api?.basePath + sub.deliveryUrl);
return;
}
setSubtitleTrack(sub.index);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle
key={`subtitle-item-title-${idx}`}
>
{sub.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))
: null}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Audio
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
{/* <DropdownMenu.CheckboxItem
key="none-item"
value="off"
onValueChange={() => {
videoRef.current?.setSubtitleTrack(-1);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key={`none-item-title`}>
None
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem> */}
{audioTracks?.length
? audioTracks?.map((a, idx: number) => (
<DropdownMenu.CheckboxItem
key={`subtitle-item-${idx}`}
value="off"
onValueChange={() => {
setAudioTrack(a.index);
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle
key={`subtitle-item-title-${idx}`}
>
{a.name}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))
: null}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
)}
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 97,
right: insets.right,
},
]}
className={`z-10 p-4
${showSkipButton ? "opacity-100" : "opacity-0"}
`}
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
<View
style={{
position: "absolute",
bottom: insets.bottom + 94,
right: insets.right,
height: 70,
}}
pointerEvents={showSkipCreditButton ? "auto" : "none"}
className={`z-10 p-4 ${
showSkipCreditButton ? "opacity-100" : "opacity-0"
}`}
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
<Pressable
onPress={() => {
toggleControls();
}}
>
<View
style={[
{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width + 100,
height: windowDimensions.height + 100,
opacity: showControls ? 1 : 0,
},
]}
className={`bg-black/50 z-0`}
></View>
</Pressable>
<View
style={{
position: "absolute",
top: 0,
left: 0,
width: windowDimensions.width,
height: windowDimensions.height,
}}
pointerEvents="none"
className={`flex flex-col items-center justify-center
${isBuffering ? "opacity-100" : "opacity-0"}
`}
>
<Loader />
</View>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-row items-center space-x-2 z-10 p-4 `}
>
<TouchableOpacity
onPress={toggleIgnoreSafeAreas}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeAreas ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={async () => {
if (stop) await stop();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
width: windowDimensions.width - insets.left - insets.right,
maxHeight: windowDimensions.height,
left: insets.left,
bottom: Platform.OS === "ios" ? insets.bottom : insets.bottom,
opacity: showControls ? 1 : 0,
},
]}
pointerEvents={showControls ? "auto" : "none"}
className={`flex flex-col p-4 `}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{item?.Name}</Text>
{item?.Type === "Episode" && (
<Text className="opacity-50">{item.SeriesName}</Text>
)}
{item?.Type === "Movie" && (
<Text className="text-xs opacity-50">{item?.ProductionYear}</Text>
)}
{item?.Type === "Audio" && (
<Text className="text-xs opacity-50">{item?.Album}</Text>
)}
</View>
<View
className={`flex flex-col-reverse py-4 px-4 rounded-2xl items-center bg-neutral-800/90`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={goToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
togglePlay(progress.value);
}}
>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={goToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View className={`flex flex-col w-full shrink`}>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</View>
);
};

View File

@@ -11,13 +11,19 @@ import { Api } from "@jellyfin/sdk";
import * as Haptics from "expo-haptics";
import { useFocusEffect } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const OfflinePlayer = () => {
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
@@ -33,6 +39,15 @@ export default function page() {
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsReady(true);
}, 2000);
return () => clearTimeout(timer);
}, []);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
@@ -95,19 +110,18 @@ export default function page() {
setIsBuffering(data.playableDuration === 0);
}, []);
if (!isReady) return null;
if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
return null;
return (
<View
style={{
width: dimensions.width,
height: dimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
@@ -160,7 +174,7 @@ export default function page() {
/>
</View>
);
}
};
export function useVideoSource(
playSettings: PlaybackType | null,
@@ -189,3 +203,5 @@ export function useVideoSource(
return videoSource;
}
export default OfflinePlayer;

View File

@@ -1,5 +1,4 @@
import { Controls } from "@/components/video-player/Controls";
import { VlcControls } from "@/components/video-player/VlcControls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { VlcPlayerView } from "@/modules/vlc-player";
@@ -21,12 +20,12 @@ import React, {
useRef,
useState,
} from "react";
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
import { Pressable, useWindowDimensions, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
import { SelectedTrackType } from "react-native-video";
export default function page() {
const OfflinePlayer = () => {
const { playSettings, playUrl } = usePlaySettings();
const api = useAtomValue(apiAtom);
const [settings] = useSettings();
@@ -178,7 +177,6 @@ export default function page() {
}}
className="flex flex-col items-center justify-center"
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
@@ -229,4 +227,6 @@ export default function page() {
)}
</View>
);
}
};
export default OfflinePlayer;

View File

@@ -32,6 +32,7 @@ import React, {
import { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
import { SafeAreaView } from "react-native-safe-area-context";
import Video, {
OnProgressData,
SelectedTrack,
@@ -39,7 +40,7 @@ import Video, {
VideoRef,
} from "react-native-video";
export const player = () => {
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
@@ -368,25 +369,20 @@ export const player = () => {
<View
style={{
flex: 1,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: dimensions.width,
height: dimensions.height,
position: "relative",
height: "100%",
width: "100%",
}}
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
style={{
flex: 1,
height: "100%",
width: "100%",
position: "absolute",
top: 0,
left: 0,
width: dimensions.width,
height: dimensions.height,
zIndex: 0,
}}
>
@@ -395,8 +391,8 @@ export const player = () => {
ref={videoRef}
source={videoSource}
style={{
width: dimensions.width,
height: dimensions.height,
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
@@ -532,3 +528,5 @@ export function useVideoSource(
return videoSource;
}
export default Player;

View File

@@ -27,24 +27,12 @@ import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Alert,
Pressable,
StatusBar,
useWindowDimensions,
View,
} from "react-native";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Pressable, useWindowDimensions, View } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { useSharedValue } from "react-native-reanimated";
export const player = () => {
const Player = () => {
const videoRef = useRef<VlcPlayerViewRef>(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -322,7 +310,6 @@ export const player = () => {
}}
className="flex flex-col items-center justify-center"
>
<SystemBars hidden />
<Pressable
onPress={() => {
setShowControls(!showControls);
@@ -409,3 +396,5 @@ export function usePoster(
return poster ?? undefined;
}
export default Player;