diff --git a/.gitignore b/.gitignore
index 01a43002..27dc1f71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ npm-debug.*
*.mobileprovision
*.orig.*
web-build/
+modules/vlc-player/android/build
# macOS
.DS_Store
@@ -26,10 +27,11 @@ package-lock.json
/ios
/android
+modules/player/android
+
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json
-/modules/vlc-player/android
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
new file mode 100644
index 00000000..b81700b5
--- /dev/null
+++ b/.idea/caches/deviceStreaming.xml
@@ -0,0 +1,329 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..639900d1
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..ba6d5c31
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml
new file mode 100644
index 00000000..d6ebd480
--- /dev/null
+++ b/.idea/streamyfin.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2fe7c24f..4571e3a1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -8,5 +8,10 @@
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
- }
+ },
+ "[swift]": {
+ "editor.defaultFormatter": "sswg.swift-lang"
+ },
+ "java.configuration.updateBuildConfiguration": "interactive",
+ "java.compile.nullAnalysis.mode": "automatic"
}
diff --git a/app.json b/app.json
index 00b060b9..67e5224f 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
- "version": "0.18.0",
+ "version": "0.21.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -99,7 +99,14 @@
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
- ]
+ ],
+ "expo-asset",
+ [
+ "react-native-edge-to-edge",
+ { "android": { "parentTheme": "Material3" } }
+ ],
+ ["react-native-bottom-tabs"],
+ ["./plugins/withChangeNativeAndroidTextToWhite.js"]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx
index f51ecbf5..83a4472e 100644
--- a/app/(auth)/(tabs)/(home)/_layout.tsx
+++ b/app/(auth)/(tabs)/(home)/_layout.tsx
@@ -27,7 +27,6 @@ export default function IndexLayout() {
onPress={() => {
router.push("/(auth)/settings");
}}
- className="p-2 "
>
diff --git a/app/(auth)/(tabs)/(home)/downloads.tsx b/app/(auth)/(tabs)/(home)/downloads.tsx
index 30ba6352..02109e99 100644
--- a/app/(auth)/(tabs)/(home)/downloads.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads.tsx
@@ -2,36 +2,46 @@ import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
-import { useDownload } from "@/providers/DownloadProvider";
+import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { router } from "expo-router";
+import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
-import { ScrollView, TouchableOpacity, View } from "react-native";
+import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-const downloads: React.FC = () => {
+export default function page() {
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
-
+ const router = useRouter();
const [settings] = useSettings();
- const movies = useMemo(
- () => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
- [downloadedFiles]
- );
+ const movies = useMemo(() => {
+ try {
+ return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
+ } catch {
+ migration_20241124();
+ return [];
+ }
+ }, [downloadedFiles]);
const groupedBySeries = useMemo(() => {
- const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
- const series: { [key: string]: BaseItemDto[] } = {};
- episodes?.forEach((e) => {
- if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
- series[e.SeriesName!].push(e);
- });
- return Object.values(series);
+ try {
+ const episodes = downloadedFiles?.filter(
+ (f) => f.item.Type === "Episode"
+ );
+ const series: { [key: string]: DownloadedItem[] } = {};
+ episodes?.forEach((e) => {
+ if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
+ series[e.item.SeriesName!].push(e);
+ });
+ return Object.values(series);
+ } catch {
+ migration_20241124();
+ return [];
+ }
}, [downloadedFiles]);
const insets = useSafeAreaInsets();
@@ -98,17 +108,20 @@ const downloads: React.FC = () => {
- {movies?.map((item: BaseItemDto) => (
-
-
+ {movies?.map((item) => (
+
+
))}
)}
- {groupedBySeries?.map((items: BaseItemDto[], index: number) => (
-
+ {groupedBySeries?.map((items, index) => (
+ i.item)}
+ key={items[0].item.SeriesId}
+ />
))}
{downloadedFiles?.length === 0 && (
@@ -118,6 +131,24 @@ const downloads: React.FC = () => {
);
-};
+}
-export default downloads;
+function migration_20241124() {
+ const router = useRouter();
+ const { deleteAllFiles } = useDownload();
+ Alert.alert(
+ "New app version requires re-download",
+ "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
+ [
+ {
+ text: "Back",
+ onPress: () => router.back(),
+ },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: async () => await deleteAllFiles(),
+ },
+ ]
+ );
+}
diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx
index 364cf52c..c9d3bcb7 100644
--- a/app/(auth)/(tabs)/(home)/index.tsx
+++ b/app/(auth)/(tabs)/(home)/index.tsx
@@ -5,7 +5,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { Colors } from "@/constants/Colors";
-import { TAB_HEIGHT } from "@/constants/Values";
+import { useRevalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
@@ -53,7 +53,6 @@ type MediaListSection = {
type Section = ScrollingCollectionListSection | MediaListSection;
export default function index() {
- const queryClient = useQueryClient();
const router = useRouter();
const api = useAtomValue(apiAtom);
@@ -68,6 +67,8 @@ export default function index() {
const { downloadedFiles } = useDownload();
const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+
useEffect(() => {
const hasDownloads = downloadedFiles && downloadedFiles.length > 0;
navigation.setOptions({
@@ -164,28 +165,13 @@ export default function index() {
);
}, [userViews]);
+ const invalidateCache = useRevalidatePlaybackProgressCache();
+
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,
- });
+ await invalidateCache();
setLoading(false);
- }, [queryClient]);
+ }, []);
const createCollectionConfig = useCallback(
(
@@ -241,7 +227,7 @@ export default function index() {
const ss: Section[] = [
{
title: "Continue Watching",
- queryKey: ["home", "resumeItems", user.Id],
+ queryKey: ["home", "resumeItems"],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
@@ -255,7 +241,7 @@ export default function index() {
},
{
title: "Next Up",
- queryKey: ["home", "nextUp-all", user?.Id],
+ queryKey: ["home", "nextUp-all"],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
@@ -361,8 +347,6 @@ export default function index() {
);
}
- const insets = useSafeAreaInsets();
-
if (e1 || e2)
return (
@@ -393,9 +377,6 @@ export default function index() {
paddingRight: insets.right,
paddingBottom: 16,
}}
- style={{
- marginBottom: TAB_HEIGHT,
- }}
>
diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
index 071d9127..8caf07c6 100644
--- a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx
@@ -2,6 +2,10 @@ import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import {
+ getMediaInfoApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
@@ -22,16 +26,18 @@ const Page: React.FC = () => {
const { data: item, isError } = useQuery({
queryKey: ["item", id],
queryFn: async () => {
- const res = await getUserItemData({
- api,
- userId: user?.Id,
+ if (!api || !user || !id) return;
+ const res = await getUserLibraryApi(api).getItem({
itemId: id,
+ userId: user?.Id,
});
- return res;
+ return res.data;
},
- enabled: !!id && !!api,
- staleTime: 60 * 1000 * 5, // 5 minutes
+ staleTime: 0,
+ refetchOnMount: true,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
});
const opacity = useSharedValue(1);
diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx
index 91ae1842..9df17c69 100644
--- a/app/(auth)/(tabs)/(search)/index.tsx
+++ b/app/(auth)/(tabs)/(search)/index.tsx
@@ -1,4 +1,3 @@
-import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
@@ -8,7 +7,6 @@ import { Loader } from "@/components/Loader";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
-import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -226,10 +224,6 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
- paddingBottom: 16,
- }}
- style={{
- marginBottom: TAB_HEIGHT,
}}
>
diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx
index b8772e68..e717d16d 100644
--- a/app/(auth)/(tabs)/_layout.tsx
+++ b/app/(auth)/(tabs)/_layout.tsx
@@ -1,87 +1,77 @@
-import { TabBarIcon } from "@/components/navigation/TabBarIcon";
+import React from "react";
+import { Platform } from "react-native";
+
+import { withLayoutContext } from "expo-router";
+
+import {
+ createNativeBottomTabNavigator,
+ NativeBottomTabNavigationEventMap,
+} from "react-native-bottom-tabs/react-navigation";
+
+const { Navigator } = createNativeBottomTabNavigator();
+
+import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
+
import { Colors } from "@/constants/Colors";
-import { BlurView } from "expo-blur";
-import * as NavigationBar from "expo-navigation-bar";
-import { Tabs } from "expo-router";
-import React, { useEffect } from "react";
-import { Platform, StyleSheet } from "react-native";
+import type {
+ ParamListBase,
+ TabNavigationState,
+} from "@react-navigation/native";
+import { SystemBars } from "react-native-edge-to-edge";
+
+export const NativeTabs = withLayoutContext<
+ BottomTabNavigationOptions,
+ typeof Navigator,
+ TabNavigationState,
+ NativeBottomTabNavigationEventMap
+>(Navigator);
export default function TabLayout() {
- useEffect(() => {
- if (Platform.OS === "android") {
- NavigationBar.setBackgroundColorAsync("#121212");
- NavigationBar.setBorderColorAsync("#121212");
- }
- }, []);
-
return (
-
- Platform.OS === "ios" ? (
-
- ) : undefined,
- }}
- >
-
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
-
+ <>
+
+
+
+
+ require("@/assets/icons/house.fill.png")
+ : () => ({ sfSymbol: "house" }),
+ }}
+ />
+
+ require("@/assets/icons/magnifyingglass.png")
+ : () => ({ sfSymbol: "magnifyingglass" }),
+ }}
+ />
+
+ require("@/assets/icons/server.rack.png")
+ : () => ({ sfSymbol: "rectangle.stack" }),
+ }}
+ />
+
+ >
);
}
diff --git a/app/(auth)/play-music.tsx b/app/(auth)/play-music.tsx
deleted file mode 100644
index 4138ecc2..00000000
--- a/app/(auth)/play-music.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-import { Text } from "@/components/common/Text";
-import AlbumCover from "@/components/posters/AlbumCover";
-import { Controls } from "@/components/video-player/Controls";
-import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
-import { useOrientation } from "@/hooks/useOrientation";
-import { useOrientationSettings } from "@/hooks/useOrientationSettings";
-import { useWebSocket } from "@/hooks/useWebsockets";
-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 { secondsToTicks } from "@/utils/secondsToTicks";
-import { Api } from "@jellyfin/sdk";
-import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
-import * as Haptics from "expo-haptics";
-import { Image } from "expo-image";
-import { useFocusEffect } from "expo-router";
-import { useAtomValue } from "jotai";
-import { debounce } from "lodash";
-import React, { useCallback, useMemo, useRef, useState } from "react";
-import { Dimensions, Pressable, StatusBar, View } from "react-native";
-import { useSharedValue } from "react-native-reanimated";
-import Video, { OnProgressData, VideoRef } from "react-native-video";
-
-export default function page() {
- const { playSettings, playUrl, playSessionId } = usePlaySettings();
- const api = useAtomValue(apiAtom);
- const [settings] = useSettings();
- const videoRef = useRef(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);
-
- if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
- return null;
-
- const togglePlay = useCallback(
- async (ticks: number) => {
- console.log("togglePlay");
- 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?.resume();
- 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(() => {
- console.log("play");
- videoRef.current?.resume();
- reportPlaybackStart();
- }, [videoRef]);
-
- const pause = useCallback(() => {
- console.log("play");
- videoRef.current?.pause();
- }, [videoRef]);
-
- const stop = useCallback(() => {
- console.log("stop");
- setIsPlaybackStopped(true);
- videoRef.current?.pause();
- 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: OnProgressData) => {
- if (isSeeking.value === true) return;
- if (isPlaybackStopped === true) return;
-
- const ticks = data.currentTime * 10000000;
-
- progress.value = secondsToTicks(data.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,
- });
-
- return (
-
-
-
-
-
-
-
- {
- setShowControls(!showControls);
- }}
- className="absolute z-0 h-full w-full opacity-0"
- >
-
-
-
-
- );
-}
-
-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(playSettings.item.UserData.PlaybackPositionTicks / 10000)
- : 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;
-}
diff --git a/app/(auth)/play-offline-video.tsx b/app/(auth)/play-offline-video.tsx
deleted file mode 100644
index 5caa17b5..00000000
--- a/app/(auth)/play-offline-video.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { Controls } from "@/components/video-player/Controls";
-import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
-import { useOrientation } from "@/hooks/useOrientation";
-import { useOrientationSettings } from "@/hooks/useOrientationSettings";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import {
- PlaybackType,
- usePlaySettings,
-} from "@/providers/PlaySettingsProvider";
-import { secondsToTicks } from "@/utils/secondsToTicks";
-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 { Pressable, StatusBar, useWindowDimensions, View } from "react-native";
-import { useSharedValue } from "react-native-reanimated";
-import Video, { OnProgressData, VideoRef } from "react-native-video";
-
-export default function page() {
- const { playSettings, playUrl } = usePlaySettings();
-
- const api = useAtomValue(apiAtom);
- const videoRef = useRef(null);
- const videoSource = useVideoSource(playSettings, api, playUrl);
- const firstTime = useRef(true);
-
- const dimensions = useWindowDimensions();
- useOrientation();
- useOrientationSettings();
- useAndroidNavigationBar();
-
- 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 togglePlay = useCallback(async () => {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- if (isPlaying) {
- videoRef.current?.pause();
- } else {
- videoRef.current?.resume();
- }
- }, [isPlaying]);
-
- const play = useCallback(() => {
- setIsPlaying(true);
- videoRef.current?.resume();
- }, [videoRef]);
-
- const stop = useCallback(() => {
- setIsPlaying(false);
- videoRef.current?.pause();
- }, [videoRef]);
-
- useFocusEffect(
- useCallback(() => {
- play();
-
- return () => {
- stop();
- };
- }, [play, stop])
- );
-
- const onProgress = useCallback(async (data: OnProgressData) => {
- if (isSeeking.value === true) return;
- progress.value = secondsToTicks(data.currentTime);
- cacheProgress.value = secondsToTicks(data.playableDuration);
- setIsBuffering(data.playableDuration === 0);
- }, []);
-
- if (!playSettings || !playUrl || !api || !videoSource || !playSettings.item)
- return null;
-
- return (
-
-
- {
- setShowControls(!showControls);
- }}
- className="absolute z-0 h-full w-full"
- >
-
-
-
-
- );
-}
-
-export function useVideoSource(
- playSettings: PlaybackType | null,
- api: Api | null,
- playUrl?: string | null
-) {
- const videoSource = useMemo(() => {
- if (!playSettings || !api || !playUrl) {
- return null;
- }
-
- const startPosition = 0;
-
- return {
- uri: playUrl,
- isNetwork: false,
- startPosition,
- metadata: {
- artist: playSettings.item?.AlbumArtist ?? undefined,
- title: playSettings.item?.Name || "Unknown",
- description: playSettings.item?.Overview ?? undefined,
- subtitle: playSettings.item?.Album ?? undefined,
- },
- };
- }, [playSettings, api]);
-
- return videoSource;
-}
diff --git a/app/(auth)/play-video.tsx b/app/(auth)/play-video.tsx
deleted file mode 100644
index e1201d7e..00000000
--- a/app/(auth)/play-video.tsx
+++ /dev/null
@@ -1,342 +0,0 @@
-import { Controls } from "@/components/video-player/Controls";
-import { useAndroidNavigationBar } from "@/hooks/useAndroidNavigationBar";
-import { useOrientation } from "@/hooks/useOrientation";
-import { useOrientationSettings } from "@/hooks/useOrientationSettings";
-import { useWebSocket } from "@/hooks/useWebsockets";
-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 { secondsToTicks } from "@/utils/secondsToTicks";
-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 React, { useCallback, useMemo, useRef, useState } from "react";
-import { Pressable, StatusBar, useWindowDimensions, 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(null);
- const poster = usePoster(playSettings, api);
- const videoSource = useVideoSource(playSettings, api, poster, playUrl);
- 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);
-
- 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?.resume();
- 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?.resume();
- reportPlaybackStart();
- }, [videoRef]);
-
- const pause = useCallback(() => {
- videoRef.current?.pause();
- }, [videoRef]);
-
- const stop = useCallback(() => {
- setIsPlaybackStopped(true);
- videoRef.current?.pause();
- 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: OnProgressData) => {
- if (isSeeking.value === true) return;
- if (isPlaybackStopped === true) return;
-
- const ticks = data.currentTime * 10000000;
-
- progress.value = secondsToTicks(data.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])
- );
-
- 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]);
-
- return (
-
-
- {
- setShowControls(!showControls);
- }}
- style={{
- position: "absolute",
- top: 0,
- left: 0,
- width: dimensions.width,
- height: dimensions.height,
- zIndex: 0,
- }}
- >
-
-
-
-
- );
-}
-
-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(playSettings.item.UserData.PlaybackPositionTicks / 10000)
- : 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;
-}
diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx
new file mode 100644
index 00000000..96d08058
--- /dev/null
+++ b/app/(auth)/player/_layout.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
new file mode 100644
index 00000000..12daaf5e
--- /dev/null
+++ b/app/(auth)/player/direct-player.tsx
@@ -0,0 +1,513 @@
+import { BITRATES } from "@/components/BitrateSelector";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { Controls } from "@/components/video-player/controls/Controls";
+import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+import { useRevalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { VlcPlayerView } from "@/modules/vlc-player";
+import {
+ PlaybackStatePayload,
+ ProgressUpdatePayload,
+ VlcPlayerViewRef,
+} from "@/modules/vlc-player/src/VlcPlayer.types";
+import { useDownload } from "@/providers/DownloadProvider";
+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, ticksToSeconds } 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, useQueryClient } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { useFocusEffect, useGlobalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Alert, Pressable, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+
+export default function page() {
+ const videoRef = useRef(null);
+ const user = useAtomValue(userAtom);
+ const api = useAtomValue(apiAtom);
+
+ 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 { getDownloadedItem } = useDownload();
+ const revalidateProgressCache = useRevalidatePlaybackProgressCache();
+
+ const setShowControls = useCallback((show: boolean) => {
+ _setShowControls(show);
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }, []);
+
+ const {
+ itemId,
+ audioIndex: audioIndexStr,
+ subtitleIndex: subtitleIndexStr,
+ mediaSourceId,
+ bitrateValue: bitrateValueStr,
+ offline: offlineStr,
+ } = useGlobalSearchParams<{
+ itemId: string;
+ audioIndex: string;
+ subtitleIndex: string;
+ mediaSourceId: string;
+ bitrateValue: string;
+ offline: string;
+ }>();
+
+ const offline = offlineStr === "true";
+
+ 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 () => {
+ console.log("Offline:", offline);
+ if (offline) {
+ const item = await getDownloadedItem(itemId);
+ if (item) return item.item;
+ }
+
+ const res = await getUserLibraryApi(api!).getItem({
+ itemId,
+ userId: user?.Id,
+ });
+
+ return res.data;
+ },
+ enabled: !!itemId,
+ staleTime: 0,
+ });
+
+ const {
+ data: stream,
+ isLoading: isLoadingStreamUrl,
+ isError: isErrorStreamUrl,
+ } = useQuery({
+ queryKey: [
+ "stream-url",
+ itemId,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ bitrateValue,
+ ],
+ queryFn: async () => {
+ console.log("Offline:", offline);
+ if (offline) {
+ const data = await getDownloadedItem(itemId);
+ if (!data?.mediaSource) return null;
+
+ const url = await getDownloadedFileUrl(data.item.Id!);
+
+ if (item)
+ return {
+ mediaSource: data.mediaSource,
+ url,
+ sessionId: undefined,
+ };
+ }
+
+ 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) {
+ Alert.alert("Error", "Failed to get stream url");
+ return null;
+ }
+
+ return {
+ mediaSource,
+ sessionId,
+ url,
+ };
+ },
+ enabled: !!itemId && !!item,
+ staleTime: 0,
+ });
+
+ const togglePlay = useCallback(
+ async (ms: number) => {
+ if (!api) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ await videoRef.current?.pause();
+
+ if (!offline && stream) {
+ 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();
+ if (!offline && stream) {
+ 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,
+ offline,
+ ]
+ );
+
+ const play = useCallback(() => {
+ videoRef.current?.play();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const reportPlaybackStopped = useCallback(async () => {
+ if (offline) return;
+
+ const currentTimeInTicks = msToTicks(progress.value);
+
+ await getPlaystateApi(api!).onPlaybackStopped({
+ itemId: item?.Id!,
+ mediaSourceId: mediaSourceId,
+ positionTicks: currentTimeInTicks,
+ playSessionId: stream?.sessionId!,
+ });
+
+ revalidateProgressCache();
+ }, [api, item, mediaSourceId, stream]);
+
+ const stop = useCallback(() => {
+ reportPlaybackStopped();
+ setIsPlaybackStopped(true);
+ videoRef.current?.stop();
+ }, [videoRef, reportPlaybackStopped]);
+
+ const reportPlaybackStart = useCallback(async () => {
+ if (offline) return;
+
+ if (!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,
+ });
+ }, [api, item, mediaSourceId, stream]);
+
+ const onProgress = useCallback(
+ async (data: ProgressUpdatePayload) => {
+ if (isSeeking.value === true) return;
+ if (isPlaybackStopped === true) return;
+
+ const { currentTime } = data.nativeEvent;
+
+ if (isBuffering) {
+ setIsBuffering(false);
+ }
+
+ progress.value = currentTime;
+
+ if (offline) return;
+
+ const currentTimeInTicks = msToTicks(currentTime);
+
+ if (!item?.Id || !stream) return;
+
+ 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,
+ offline,
+ });
+
+ const onPlaybackStateChanged = useCallback((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);
+ }
+ }, []);
+
+ const startPosition = useMemo(() => {
+ if (offline) return 0;
+
+ return item?.UserData?.PlaybackPositionTicks
+ ? ticksToSeconds(item.UserData.PlaybackPositionTicks)
+ : 0;
+ }, [item]);
+
+ useFocusEffect(
+ React.useCallback(() => {
+ return async () => {
+ videoRef.current?.stop();
+ };
+ }, [])
+ );
+
+ // Preselection of audio and subtitle tracks.
+
+ let initOptions = ["--sub-text-scale=60"];
+ let externalTrack = { name: "", DeliveryUrl: "" };
+
+ const allSubs =
+ stream?.mediaSource.MediaStreams?.filter(
+ (sub) => sub.Type === "Subtitle"
+ ) || [];
+ const chosenSubtitleTrack = allSubs.find(
+ (sub) => sub.Index === subtitleIndex
+ );
+ const allAudio =
+ stream?.mediaSource.MediaStreams?.filter(
+ (audio) => audio.Type === "Audio"
+ ) || [];
+ const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
+
+ // Direct playback CASE
+ if (!bitrateValue) {
+ // If Subtitle is embedded we can use the position to select it straight away.
+ if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
+ initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
+ } else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
+ // If Subtitle is external we need to pass the URL to the player.
+ externalTrack = {
+ name: chosenSubtitleTrack.DisplayTitle || "",
+ DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
+ };
+ }
+
+ if (chosenAudioTrack)
+ initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
+ } else {
+ // Transcoded playback CASE
+ if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
+ externalTrack = {
+ name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
+ DeliveryUrl: "",
+ };
+ }
+ }
+
+ if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
+ return (
+
+
+
+ );
+
+ if (isErrorItem || isErrorStreamUrl)
+ return (
+
+ Error
+
+ );
+
+ return (
+
+
+ {}}
+ 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);
+ }}
+ />
+
+
+
+
+
+ {videoRef.current && (
+
+ )}
+
+ );
+}
+
+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;
+}
diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx
new file mode 100644
index 00000000..13aa4ecc
--- /dev/null
+++ b/app/(auth)/player/music-player.tsx
@@ -0,0 +1,420 @@
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { Controls } from "@/components/video-player/controls/Controls";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ getPlaystateApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { Image } from "expo-image";
+import { useFocusEffect, useLocalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Pressable, useWindowDimensions, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, { OnProgressData, VideoRef } from "react-native-video";
+
+export default function page() {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const [settings] = useSettings();
+ const videoRef = useRef(null);
+ const windowDimensions = useWindowDimensions();
+
+ const firstTime = useRef(true);
+
+ 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) 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"],
+ 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,
+ });
+
+ if (!res) return null;
+
+ const { mediaSource, sessionId, url } = res;
+
+ if (!sessionId || !mediaSource || !url) return null;
+
+ return {
+ mediaSource,
+ sessionId,
+ url,
+ };
+ },
+ });
+
+ 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,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ stream,
+ ]
+ );
+
+ const play = useCallback(() => {
+ console.log("play");
+ videoRef.current?.resume();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ console.log("play");
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const stop = useCallback(() => {
+ console.log("stop");
+ 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 = data.currentTime * 10000000;
+
+ progress.value = secondsToTicks(data.currentTime);
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+ 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,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ stream,
+ ]
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ play();
+
+ return () => {
+ stop();
+ };
+ }, [play, stop])
+ );
+
+ useOrientation();
+ useOrientationSettings();
+
+ useWebSocket({
+ isPlaying: isPlaying,
+ pauseVideo: pause,
+ playVideo: play,
+ stopPlayback: stop,
+ });
+
+ if (isLoadingItem || isLoadingStreamUrl)
+ return (
+
+
+
+ );
+
+ if (isErrorItem || isErrorStreamUrl)
+ return (
+
+ Error
+
+ );
+
+ if (!item || !stream)
+ return (
+
+ Error
+
+ );
+
+ return (
+
+
+
+
+
+ {
+ setShowControls(!showControls);
+ }}
+ className="absolute z-0 h-full w-full opacity-0"
+ >
+ {videoSource && (
+
+
+
+
+ );
+}
+
+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]);
+
+ return videoSource;
+}
diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx
new file mode 100644
index 00000000..a95e540f
--- /dev/null
+++ b/app/(auth)/player/transcoding-player.tsx
@@ -0,0 +1,580 @@
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { TrackInfo } from "@/modules/vlc-player";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ getPlaystateApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { useFocusEffect, useLocalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { Pressable, useWindowDimensions, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, {
+ OnProgressData,
+ SelectedTrack,
+ SelectedTrackType,
+ VideoRef,
+} from "react-native-video";
+import { Controls } from "@/components/video-player/controls/Controls";
+import transcoding from "@/utils/profiles/transcoding";
+import { useRevalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
+
+const Player = () => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const [settings] = useSettings();
+ const videoRef = useRef(null);
+
+ const firstTime = useRef(true);
+ const revalidateProgressCache = useRevalidatePlaybackProgressCache();
+
+ 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 setShowControls = useCallback((show: boolean) => {
+ _setShowControls(show);
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }, []);
+
+ 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,
+ mediaSourceId,
+ ],
+
+ queryFn: async () => {
+ if (!api) {
+ throw new Error("No api");
+ }
+
+ if (!item) {
+ console.warn("No item", itemId, item);
+ return null;
+ }
+
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: audioIndex,
+ maxStreamingBitrate: bitrateValue,
+ mediaSourceId: mediaSourceId,
+ subtitleStreamIndex: subtitleIndex,
+ deviceProfile: transcoding,
+ });
+
+ if (!res) return null;
+
+ const { mediaSource, sessionId, url } = res;
+
+ if (!sessionId || !mediaSource || !url) {
+ console.warn("No sessionId or mediaSource or url", url);
+ return null;
+ }
+
+ return {
+ mediaSource,
+ sessionId,
+ url,
+ };
+ },
+ enabled: !!item,
+ staleTime: 0,
+ });
+
+ const poster = usePoster(item, api);
+ const videoSource = useVideoSource(item, api, poster, stream?.url);
+
+ const togglePlay = useCallback(
+ async (ticks: number) => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ videoRef.current?.pause();
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(ticks),
+ isPaused: true,
+ playMethod: stream?.url.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ } else {
+ videoRef.current?.resume();
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(ticks),
+ isPaused: false,
+ playMethod: stream?.url.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ }
+ },
+ [
+ isPlaying,
+ api,
+ item,
+ videoRef,
+ settings,
+ stream,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ ]
+ );
+
+ const play = useCallback(() => {
+ videoRef.current?.resume();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const 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,
+ });
+ revalidateProgressCache();
+ };
+
+ const stop = useCallback(() => {
+ reportPlaybackStopped();
+ videoRef.current?.pause();
+ setIsPlaybackStopped(true);
+ }, [videoRef, reportPlaybackStopped]);
+
+ 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,
+ ]
+ );
+
+ 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([]);
+ const [selectedAudioTrack, setSelectedAudioTrack] = useState<
+ SelectedTrack | undefined
+ >(undefined);
+
+ // Set intial Subtitle Track.
+ // We will only select external tracks if they are are text based. Else it should be burned in already.
+ const textSubs =
+ stream?.mediaSource.MediaStreams?.filter(
+ (sub) => sub.Type === "Subtitle" && sub.IsTextSubtitleStream
+ ) || [];
+
+ const uniqueTextSubs = Array.from(
+ new Set(textSubs.map((sub) => sub.DisplayTitle))
+ ).map((title) => textSubs.find((sub) => sub.DisplayTitle === title));
+ const chosenSubtitleTrack = textSubs.find(
+ (sub) => sub.Index === subtitleIndex
+ );
+ useEffect(() => {
+ if (chosenSubtitleTrack && selectedTextTrack === undefined) {
+ console.log("Setting selected text track", chosenSubtitleTrack);
+ setSelectedTextTrack({
+ type: SelectedTrackType.INDEX,
+ value: uniqueTextSubs.indexOf(chosenSubtitleTrack),
+ });
+ }
+ }, [embededTextTracks]);
+
+ const getAudioTracks = (): TrackInfo[] => {
+ return audioTracks.map((t) => ({
+ name: t.name,
+ index: t.index,
+ }));
+ };
+
+ const getSubtitleTracks = (): TrackInfo[] => {
+ return embededTextTracks.map((t) => ({
+ name: t.title ?? "",
+ index: t.index,
+ language: t.language,
+ }));
+ };
+
+ useFocusEffect(
+ useCallback(() => {
+ play();
+
+ return () => {
+ videoRef.current?.pause();
+ stop();
+ };
+ }, [])
+ );
+
+ if (isLoadingItem || isLoadingStreamUrl)
+ return (
+
+
+
+ );
+
+ if (isErrorItem || isErrorStreamUrl)
+ return (
+
+ Error
+
+ );
+
+ return (
+
+
+ {videoSource ? (
+ <>
+
+
+ {item && (
+ {
+ if (i === -1) {
+ setSelectedTextTrack({
+ type: SelectedTrackType.DISABLED,
+ value: undefined,
+ });
+ return;
+ }
+ setSelectedTextTrack({
+ type: SelectedTrackType.INDEX,
+ value: i,
+ });
+ }}
+ getAudioTracks={getAudioTracks}
+ setAudioTrack={(i) => {
+ console.log("setAudioTrack ~", i);
+ setSelectedAudioTrack({
+ type: SelectedTrackType.INDEX,
+ value: i,
+ });
+ }}
+ />
+ )}
+
+ );
+};
+
+export function usePoster(
+ item: BaseItemDto | null | undefined,
+ api: Api | null
+): string | undefined {
+ const poster = useMemo(() => {
+ if (!item || !api) return undefined;
+ return item.Type === "Audio"
+ ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
+ : getBackdropUrl({
+ api,
+ item: item,
+ quality: 70,
+ width: 200,
+ });
+ }, [item, api]);
+
+ return poster ?? undefined;
+}
+
+export function useVideoSource(
+ item: BaseItemDto | null | undefined,
+ api: Api | null,
+ poster: string | undefined,
+ url?: string | null
+) {
+ const videoSource = useMemo(() => {
+ if (!item || !api || !url) {
+ return null;
+ }
+
+ const startPosition = item?.UserData?.PlaybackPositionTicks
+ ? Math.round(item.UserData.PlaybackPositionTicks / 10000)
+ : 0;
+
+ return {
+ uri: url,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: item?.AlbumArtist ?? undefined,
+ title: item?.Name || "Unknown",
+ description: item?.Overview ?? undefined,
+ imageUri: poster,
+ subtitle: item?.Album ?? undefined,
+ },
+ };
+ }, [item, api, poster, url]);
+
+ return videoSource;
+}
+
+export default Player;
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 9c32fc76..f40a2f9a 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,7 +1,7 @@
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
- getTokenFromStoraage,
+ getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
@@ -10,6 +10,7 @@ import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { writeToLog } from "@/utils/log";
+import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
@@ -19,7 +20,6 @@ import {
completeHandler,
download,
} from "@kesha-antonov/react-native-background-downloader";
-import AsyncStorage from "@react-native-async-storage/async-storage";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
@@ -31,11 +31,11 @@ import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
-import { StatusBar } from "expo-status-bar";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react";
-import { AppState } from "react-native";
+import { Appearance, AppState } from "react-native";
+import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { Toaster } from "sonner-native";
@@ -86,7 +86,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const now = Date.now();
- const settingsData = await AsyncStorage.getItem("settings");
+ const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
@@ -96,19 +96,13 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
- const token = await getTokenFromStoraage();
- const deviceId = await getOrSetDeviceId();
+ const token = getTokenFromStorage();
+ const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
- console.log({
- token,
- url,
- deviceId,
- });
-
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
@@ -120,14 +114,6 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
- console.log({
- token,
- deviceId,
- baseDirectory,
- url,
- downloadUrl,
- });
-
const tasks = await checkForExistingDownloads();
if (tasks.find((task) => task.id === job.id)) {
@@ -137,7 +123,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
download({
id: job.id,
- url: url + "download/" + job.id,
+ url: downloadUrl,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
@@ -191,7 +177,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
const checkAndRequestPermissions = async () => {
try {
- const hasAskedBefore = await AsyncStorage.getItem(
+ const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission"
);
@@ -206,7 +192,7 @@ const checkAndRequestPermissions = async () => {
console.log("Notification permissions denied.");
}
- await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
+ storage.set("hasAskedForNotificationPermission", "true");
} else {
console.log("Already asked for notification permissions before.");
}
@@ -231,6 +217,8 @@ export default function RootLayout() {
}
}, [loaded]);
+ Appearance.setColorScheme("dark");
+
if (!loaded) {
return null;
}
@@ -326,7 +314,7 @@ function Layout() {
-
+
null,
}}
/>
-
- null,
}}
/>
{
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
- console.log(`Request to ${protocol}${url} timed out`);
+ console.error(`Request to ${protocol}${url} timed out`);
} else {
- console.log(`Error checking ${protocol}${url}:`, error);
+ console.error(`Error checking ${protocol}${url}:`, error);
}
}
}
@@ -286,7 +286,7 @@ const Login: React.FC = () => {
diff --git a/assets/icons/house.fill.png b/assets/icons/house.fill.png
new file mode 100644
index 00000000..9e32f71e
Binary files /dev/null and b/assets/icons/house.fill.png differ
diff --git a/assets/icons/magnifyingglass.png b/assets/icons/magnifyingglass.png
new file mode 100644
index 00000000..5fc44c41
Binary files /dev/null and b/assets/icons/magnifyingglass.png differ
diff --git a/assets/icons/server.rack.png b/assets/icons/server.rack.png
new file mode 100644
index 00000000..245e5ad2
Binary files /dev/null and b/assets/icons/server.rack.png differ
diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx
index 2c17fe7e..75fd659c 100644
--- a/components/AudioTrackSelector.tsx
+++ b/components/AudioTrackSelector.tsx
@@ -5,9 +5,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps {
- source: MediaSourceInfo;
+ source?: MediaSourceInfo;
onChange: (value: number) => void;
- selected?: number | null;
+ selected?: number | undefined;
}
export const AudioTrackSelector: React.FC = ({
@@ -17,7 +17,7 @@ export const AudioTrackSelector: React.FC = ({
...props
}) => {
const audioStreams = useMemo(
- () => source.MediaStreams?.filter((x) => x.Type === "Audio"),
+ () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source]
);
diff --git a/components/BitrateSelector.tsx b/components/BitrateSelector.tsx
index 78bcc63d..0f1bd28b 100644
--- a/components/BitrateSelector.tsx
+++ b/components/BitrateSelector.tsx
@@ -6,7 +6,6 @@ import { useMemo } from "react";
export type Bitrate = {
key: string;
value: number | undefined;
- height?: number;
};
export const BITRATES: Bitrate[] = [
@@ -27,17 +26,14 @@ export const BITRATES: Bitrate[] = [
{
key: "2 Mb/s",
value: 2000000,
- height: 720,
},
{
key: "500 Kb/s",
value: 500000,
- height: 480,
},
{
key: "250 Kb/s",
value: 250000,
- height: 480,
},
].sort((a, b) => (b.value || Infinity) - (a.value || Infinity));
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index 41a4ff26..716be099 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -3,9 +3,9 @@ import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
-import ios from "@/utils/profiles/ios";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import native from "@/utils/profiles/native";
-import old from "@/utils/profiles/old";
import Ionicons from "@expo/vector-icons/Ionicons";
import {
BottomSheetBackdrop,
@@ -21,17 +21,17 @@ import { router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, TouchableOpacity, View, ViewProps } from "react-native";
+import { MMKV } from "react-native-mmkv";
+import { toast } from "sonner-native";
import { AudioTrackSelector } from "./AudioTrackSelector";
-import { Bitrate, BITRATES, BitrateSelector } from "./BitrateSelector";
+import { Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
-import { toast } from "sonner-native";
-import iosFmp4 from "@/utils/profiles/iosFmp4";
-import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -43,10 +43,10 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { processes, startBackgroundDownload } = useDownload();
- const { startRemuxing } = useRemuxHlsToMp4(item);
+ const { startRemuxing } = useRemuxHlsToMp4();
const [selectedMediaSource, setSelectedMediaSource] = useState<
- MediaSourceInfo | undefined
+ MediaSourceInfo | undefined | null
>(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
@@ -63,7 +63,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
getDefaultPlaySettings(item, settings);
// 4. Set states
- setSelectedMediaSource(mediaSource);
+ setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
@@ -99,81 +99,36 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
);
}
- let deviceProfile: any = iosFmp4;
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: selectedAudioStream,
+ maxStreamingBitrate: maxBitrate.value,
+ mediaSourceId: selectedMediaSource.Id,
+ subtitleStreamIndex: selectedSubtitleStream,
+ deviceProfile: native,
+ });
- if (settings?.deviceProfile === "Native") {
- deviceProfile = native;
- } else if (settings?.deviceProfile === "Old") {
- deviceProfile = old;
+ if (!res) {
+ Alert.alert(
+ "Something went wrong",
+ "Could not get stream url from Jellyfin"
+ );
+ return;
}
- const response = await api.axiosInstance.post(
- `${api.basePath}/Items/${item.Id}/PlaybackInfo`,
- {
- DeviceProfile: deviceProfile,
- UserId: user.Id,
- MaxStreamingBitrate: maxBitrate.value,
- StartTimeTicks: 0,
- EnableTranscoding: maxBitrate.value ? true : undefined,
- AutoOpenLiveStream: true,
- AllowVideoStreamCopy: maxBitrate.value ? false : true,
- MediaSourceId: selectedMediaSource?.Id,
- AudioStreamIndex: selectedAudioStream,
- SubtitleStreamIndex: selectedSubtitleStream,
- },
- {
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- }
- );
+ const { mediaSource, url } = res;
- let url: string | undefined = undefined;
- let fileExtension: string | undefined | null = "mp4";
+ if (!url || !mediaSource) throw new Error("No url");
- const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
- (source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
- );
-
- if (!mediaSource) {
- throw new Error("No media source");
- }
-
- if (mediaSource.SupportsDirectPlay) {
- if (item.MediaType === "Video") {
- url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
- } else if (item.MediaType === "Audio") {
- console.log("Using direct stream for audio!");
- const searchParams = new URLSearchParams({
- UserId: user.Id,
- DeviceId: api.deviceInfo.id,
- MaxStreamingBitrate: "140000000",
- Container:
- "opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
- TranscodingContainer: "mp4",
- TranscodingProtocol: "hls",
- AudioCodec: "aac",
- api_key: api.accessToken,
- StartTimeTicks: "0",
- EnableRedirection: "true",
- EnableRemoteMedia: "false",
- });
- url = `${api.basePath}/Audio/${
- item.Id
- }/universal?${searchParams.toString()}`;
- }
- } else if (mediaSource.TranscodingUrl) {
- url = `${api.basePath}${mediaSource.TranscodingUrl}`;
- fileExtension = mediaSource.TranscodingContainer;
- }
-
- if (!url) throw new Error("No url");
- if (!fileExtension) throw new Error("No file extension");
+ saveDownloadItemInfoToDiskTmp(item, mediaSource, url);
if (settings?.downloadMethod === "optimized") {
- return await startBackgroundDownload(url, item, fileExtension);
+ return await startBackgroundDownload(url, item, mediaSource);
} else {
- return await startRemuxing(url);
+ return await startRemuxing(item, url, mediaSource);
}
}, [
api,
@@ -195,7 +150,7 @@ export const DownloadItem: React.FC = ({ item, ...props }) => {
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
- return downloadedFiles.some((file) => file.Id === item.Id);
+ return downloadedFiles.some((file) => file.item.Id === item.Id);
}, [downloadedFiles, item.Id]);
const renderBackdrop = useCallback(
diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx
index 07155b8c..c555fba4 100644
--- a/components/GenreTags.tsx
+++ b/components/GenreTags.tsx
@@ -12,11 +12,8 @@ export const GenreTags: React.FC = ({ genres }) => {
return (
- {genres.map((genre) => (
-
+ {genres.map((genre, idx) => (
+
{genre}
))}
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
index eb5e82e8..627c3a92 100644
--- a/components/ItemContent.tsx
+++ b/components/ItemContent.tsx
@@ -11,126 +11,73 @@ import { ItemImage } from "@/components/common/ItemImage";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
+import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColors } from "@/hooks/useImageColors";
+import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom } from "@/providers/JellyfinProvider";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { useSettings } from "@/utils/atoms/settings";
-import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
-import { useFocusEffect, useNavigation } from "expo-router";
+import { useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
-import React, { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, View } from "react-native";
+import React, { useEffect, useMemo, useState } from "react";
+import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
+export type SelectedOptions = {
+ bitrate: Bitrate;
+ mediaSource: MediaSourceInfo | undefined;
+ audioIndex: number | undefined;
+ subtitleIndex: number;
+};
+
export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
({ item }) => {
const [api] = useAtom(apiAtom);
- const { setPlaySettings, playUrl, playSettings } = usePlaySettings();
const [settings] = useSettings();
+ const { orientation } = useOrientation();
const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ useImageColors({ item });
const [loadingLogo, setLoadingLogo] = useState(true);
-
- const [orientation, setOrientation] = useState(
- ScreenOrientation.Orientation.PORTRAIT_UP
- );
-
- useFocusEffect(
- useCallback(() => {
- if (!settings) return;
- const { bitrate, mediaSource, audioIndex, subtitleIndex } =
- getDefaultPlaySettings(item, settings);
-
- setPlaySettings({
- item,
- bitrate,
- mediaSource,
- audioIndex,
- subtitleIndex,
- });
-
- if (!mediaSource) {
- Alert.alert("Error", "No media source found for this item.");
- navigation.goBack();
- }
- }, [item, settings])
- );
-
- const selectedMediaSource = useMemo(() => {
- return playSettings?.mediaSource || undefined;
- }, [playSettings?.mediaSource]);
-
- const setSelectedMediaSource = (mediaSource: MediaSourceInfo) => {
- setPlaySettings((prev) => ({
- ...prev,
- mediaSource,
- }));
- };
-
- const selectedAudioStream = useMemo(() => {
- return playSettings?.audioIndex;
- }, [playSettings?.audioIndex]);
-
- const setSelectedAudioStream = (audioIndex: number) => {
- setPlaySettings((prev) => ({
- ...prev,
- audioIndex,
- }));
- };
-
- const selectedSubtitleStream = useMemo(() => {
- return playSettings?.subtitleIndex;
- }, [playSettings?.subtitleIndex]);
-
- const setSelectedSubtitleStream = (subtitleIndex: number) => {
- setPlaySettings((prev) => ({
- ...prev,
- subtitleIndex,
- }));
- };
-
- const maxBitrate = useMemo(() => {
- return playSettings?.bitrate;
- }, [playSettings?.bitrate]);
-
- const setMaxBitrate = (bitrate: Bitrate | undefined) => {
- console.log("setMaxBitrate", bitrate);
- setPlaySettings((prev) => ({
- ...prev,
- bitrate,
- }));
- };
-
- useEffect(() => {
- const subscription = ScreenOrientation.addOrientationChangeListener(
- (event) => {
- setOrientation(event.orientationInfo.orientation);
- }
- );
-
- ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
- setOrientation(initialOrientation);
- });
-
- return () => {
- ScreenOrientation.removeOrientationChangeListener(subscription);
- };
- }, []);
-
const [headerHeight, setHeaderHeight] = useState(350);
- useImageColors({ item });
+ const [selectedOptions, setSelectedOptions] = useState<
+ SelectedOptions | undefined
+ >(undefined);
+
+ const {
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultMediaSource,
+ defaultSubtitleIndex,
+ } = useDefaultPlaySettings(item, settings);
+
+ // Needs to automatically change the selected to the default values for default indexes.
+ useEffect(() => {
+ console.log(defaultAudioIndex, defaultSubtitleIndex);
+ setSelectedOptions(() => ({
+ bitrate: defaultBitrate,
+ mediaSource: defaultMediaSource,
+ subtitleIndex: defaultSubtitleIndex ?? -1,
+ audioIndex: defaultAudioIndex,
+ }));
+ }, [
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultSubtitleIndex,
+ defaultMediaSource,
+ ]);
useEffect(() => {
navigation.setOptions({
@@ -150,13 +97,9 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
}, [item]);
useEffect(() => {
- // If landscape
- if (orientation !== ScreenOrientation.Orientation.PORTRAIT_UP) {
+ if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
setHeaderHeight(230);
- return;
- }
-
- if (item.Type === "Movie") setHeaderHeight(500);
+ else if (item.Type === "Movie") setHeaderHeight(500);
else setHeaderHeight(350);
}, [item.Type, orientation]);
@@ -166,7 +109,7 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
- const insets = useSafeAreaInsets();
+ if (!selectedOptions) return null;
return (
= React.memo(
setMaxBitrate(val)}
- selected={maxBitrate}
+ onChange={(val) =>
+ setSelectedOptions(
+ (prev) => prev && { ...prev, bitrate: val }
+ )
+ }
+ selected={selectedOptions.bitrate}
/>
+ setSelectedOptions(
+ (prev) =>
+ prev && {
+ ...prev,
+ mediaSource: val,
+ }
+ )
+ }
+ selected={selectedOptions.mediaSource}
+ />
+ {
+ console.log(val);
+ setSelectedOptions(
+ (prev) =>
+ prev && {
+ ...prev,
+ audioIndex: val,
+ }
+ );
+ }}
+ selected={selectedOptions.audioIndex}
+ />
+
+ setSelectedOptions(
+ (prev) =>
+ prev && {
+ ...prev,
+ subtitleIndex: val,
+ }
+ )
+ }
+ selected={selectedOptions.subtitleIndex}
/>
- {selectedMediaSource && (
- <>
-
-
- >
- )}
)}
-
+
{item.Type === "Episode" && (
@@ -260,10 +232,10 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
{item.People && item.People.length > 0 && (
- {item.People.slice(0, 3).map((person) => (
+ {item.People.slice(0, 3).map((person, idx) => (
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
index 79e8e5e5..34f02fd9 100644
--- a/components/MediaSourceSelector.tsx
+++ b/components/MediaSourceSelector.tsx
@@ -26,23 +26,9 @@ export const MediaSourceSelector: React.FC = ({
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video"
)?.DisplayTitle || "",
- [item.MediaSources, selected]
+ [item, selected]
);
- useEffect(() => {
- if (!selected && item.MediaSources && item.MediaSources.length > 0) {
- onChange(item.MediaSources[0]);
- }
- }, [item.MediaSources, selected]);
-
- const name = (name?: string | null) => {
- if (name && name.length > 40)
- return (
- name.substring(0, 20) + " [...] " + name.substring(name.length - 20)
- );
- return name;
- };
-
return (
= ({
);
};
+
+const name = (name?: string | null) => {
+ if (name && name.length > 40)
+ return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
+ return name;
+};
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 69d256c2..fef2f91e 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -1,14 +1,18 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
+import { useSettings } from "@/utils/atoms/settings";
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useRouter } from "expo-router";
import { useAtom, useAtomValue } from "jotai";
-import { useCallback, useEffect, useMemo } from "react";
-import { Alert, Linking, TouchableOpacity, View } from "react-native";
+import { useCallback, useEffect } from "react";
+import { Alert, TouchableOpacity, View } from "react-native";
import CastContext, {
CastButton,
PlayServicesState,
@@ -26,20 +30,21 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import { Button } from "./Button";
-import { Text } from "./common/Text";
-import { useRouter } from "expo-router";
-import { useSettings } from "@/utils/atoms/settings";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { chromecastProfile } from "@/utils/profiles/chromecast";
-import { usePlaySettings } from "@/providers/PlaySettingsProvider";
+import { SelectedOptions } from "./ItemContent";
-interface Props extends React.ComponentProps {}
+interface Props extends React.ComponentProps {
+ item: BaseItemDto;
+ selectedOptions: SelectedOptions;
+}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
-export const PlayButton: React.FC = ({ ...props }) => {
- const { playSettings, playUrl: url } = usePlaySettings();
+export const PlayButton: React.FC = ({
+ item,
+ selectedOptions,
+ ...props
+}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const mediaStatus = useMediaStatus();
@@ -58,32 +63,32 @@ export const PlayButton: React.FC = ({ ...props }) => {
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
- const directStream = useMemo(() => {
- return !url?.includes("m3u8");
- }, [url]);
-
- const item = useMemo(() => {
- return playSettings?.item;
- }, [playSettings?.item]);
-
- const onPress = useCallback(async () => {
- if (!url || !item) {
- console.warn(
- "No URL or item provided to PlayButton",
- url?.slice(0, 100),
- item?.Id
- );
- return;
- }
-
- if (!client) {
- const vlcLink = "vlc://" + url;
- if (vlcLink && settings?.openInVLC) {
- Linking.openURL(vlcLink);
+ const goToPlayer = useCallback(
+ (q: string, bitrateValue: number | undefined) => {
+ if (!bitrateValue) {
+ router.push(`/player/direct-player?${q}`);
return;
}
+ router.push(`/player/transcoding-player?${q}`);
+ },
+ [router]
+ );
- router.push("/play-video");
+ const onPress = useCallback(async () => {
+ if (!item) return;
+
+ const queryParams = new URLSearchParams({
+ itemId: item.Id!,
+ audioIndex: selectedOptions.audioIndex?.toString() ?? "",
+ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
+ mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
+ bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
+ });
+
+ const queryString = queryParams.toString();
+
+ if (!client) {
+ goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
@@ -116,15 +121,14 @@ export const PlayButton: React.FC = ({ ...props }) => {
// Get a new URL with the Chromecast device profile:
const data = await getStreamUrl({
api,
- deviceProfile: chromecastProfile,
item,
- mediaSourceId: playSettings?.mediaSource?.Id,
- startTimeTicks: 0,
- maxStreamingBitrate: playSettings?.bitrate?.value,
- audioStreamIndex: playSettings?.audioIndex ?? 0,
- subtitleStreamIndex: playSettings?.subtitleIndex ?? -1,
+ deviceProfile: ios,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
- forceDirectPlay: settings?.forceDirectPlay,
+ audioStreamIndex: selectedOptions.audioIndex,
+ maxStreamingBitrate: selectedOptions.bitrate?.value,
+ mediaSourceId: selectedOptions.mediaSource?.Id,
+ subtitleStreamIndex: selectedOptions.subtitleIndex,
});
if (!data?.url) {
@@ -205,7 +209,7 @@ export const PlayButton: React.FC = ({ ...props }) => {
});
break;
case 1:
- router.push("/play-video");
+ goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;
@@ -213,16 +217,15 @@ export const PlayButton: React.FC = ({ ...props }) => {
}
);
}, [
- url,
item,
client,
settings,
api,
user,
- playSettings,
router,
showActionSheetWithOptions,
mediaStatus,
+ selectedOptions,
]);
const derivedTargetWidth = useDerivedValue(() => {
@@ -317,10 +320,11 @@ export const PlayButton: React.FC = ({ ...props }) => {
return (
@@ -372,7 +376,7 @@ export const PlayButton: React.FC = ({ ...props }) => {
-
+ {/*
= ({ ...props }) => {
{directStream ? "Direct stream" : "Transcoded stream"}
-
+ */}
);
};
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index 82e9057d..0d483858 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -41,9 +41,6 @@ export const PlayedStatus: React.FC = ({ item, ...props }) => {
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
- queryClient.invalidateQueries({
- queryKey: ["nextUp-all"],
- });
queryClient.invalidateQueries({
queryKey: ["home"],
});
diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx
index 23f2ebb2..144d20d6 100644
--- a/components/SubtitleTrackSelector.tsx
+++ b/components/SubtitleTrackSelector.tsx
@@ -6,9 +6,9 @@ import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
interface Props extends React.ComponentProps {
- source: MediaSourceInfo;
+ source?: MediaSourceInfo;
onChange: (value: number) => void;
- selected?: number | null;
+ selected?: number | undefined;
}
export const SubtitleTrackSelector: React.FC = ({
@@ -18,7 +18,7 @@ export const SubtitleTrackSelector: React.FC = ({
...props
}) => {
const subtitleStreams = useMemo(
- () => source.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
+ () => source?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [],
[source]
);
diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx
index 6679453f..2dce75d4 100644
--- a/components/common/HorrizontalScroll.tsx
+++ b/components/common/HorrizontalScroll.tsx
@@ -16,6 +16,7 @@ interface HorizontalScrollProps
> {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
+ keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
@@ -32,6 +33,7 @@ export const HorizontalScroll = forwardRef<
(
{
data = [],
+ keyExtractor,
renderItem,
containerStyle,
contentContainerStyle,
@@ -91,6 +93,7 @@ export const HorizontalScroll = forwardRef<
paddingHorizontal: 16,
...contentContainerStyle,
}}
+ keyExtractor={keyExtractor}
ListEmptyComponent={() => (
@@ -98,6 +101,7 @@ export const HorizontalScroll = forwardRef<
)}
+ {...props}
/>
);
}
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
index 2f68689f..9a418357 100644
--- a/components/downloads/ActiveDownloads.tsx
+++ b/components/downloads/ActiveDownloads.tsx
@@ -26,7 +26,7 @@ import { storage } from "@/utils/mmkv";
interface Props extends ViewProps {}
export const ActiveDownloads: React.FC = ({ ...props }) => {
- const { processes, startDownload } = useDownload();
+ const { processes } = useDownload();
if (processes?.length === 0)
return (
@@ -85,7 +85,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
toast.success("Download canceled");
},
onError: (e) => {
- console.log(e);
+ console.error(e);
toast.error("Could not cancel download");
},
});
@@ -95,7 +95,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const length = p?.item?.RunTimeTicks || 0;
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
- return formatTimeString(timeLeft, true);
+ return formatTimeString(timeLeft, "tick");
};
const base64Image = useMemo(() => {
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 3d95821c..80a50d3d 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -7,7 +7,7 @@ import {
useActionSheet,
} from "@expo/react-native-action-sheet";
-import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
@@ -26,7 +26,7 @@ interface EpisodeCardProps {
*/
export const EpisodeCard: React.FC = ({ item }) => {
const { deleteFile } = useDownload();
- const { openFile } = useFileOpener();
+ const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const base64Image = useMemo(() => {
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 54381c08..8b275d54 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -1,20 +1,17 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as Haptics from "expo-haptics";
-import React, { useCallback, useMemo } from "react";
-import { TouchableOpacity, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import * as Haptics from "expo-haptics";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
-import { runtimeTicksToMinutes } from "@/utils/time";
-import { Text } from "../common/Text";
-
-import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
-import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
@@ -28,7 +25,7 @@ interface MovieCardProps {
*/
export const MovieCard: React.FC = ({ item }) => {
const { deleteFile } = useDownload();
- const { openFile } = useFileOpener();
+ const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index 03cb37e9..c7b918b3 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -31,8 +31,10 @@ export const ScrollingCollectionList: React.FC = ({
const { data, isLoading } = useQuery({
queryKey,
queryFn,
- enabled: !disabled,
staleTime: 0,
+ refetchOnMount: true,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
});
if (disabled || !title) return null;
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index 367b763b..552baa69 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -103,7 +103,7 @@ export const SongsListItem: React.FC = ({
});
} else {
console.log("Playing on device", data.url, item.Id);
- router.push("/play-music");
+ router.push("/music-player");
}
}, []);
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 5e4ec379..41dfdbb1 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -6,7 +6,7 @@ import {
} from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
-import React from "react";
+import React, { useMemo } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
@@ -20,24 +20,37 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
+ const destinctPeople = useMemo(() => {
+ const people: BaseItemPerson[] = [];
+ item?.People?.forEach((person) => {
+ const existingPerson = people.find((p) => p.Id === person.Id);
+ if (existingPerson) {
+ existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
+ } else {
+ people.push(person);
+ }
+ });
+ return people;
+ }, [item?.People]);
+
return (
Cast & Crew
i.Id.toString()}
height={247}
- data={item?.People || []}
- renderItem={(item, index) => (
+ data={destinctPeople}
+ renderItem={(i) => (
{
- router.push(`/actors/${item.Id}`);
+ router.push(`/actors/${i.Id}`);
}}
- key={item.Id}
className="flex flex-col w-28"
>
-
- {item.Name}
- {item.Role}
+
+ {i.Name}
+ {i.Role}
)}
/>
diff --git a/components/settings/SettingToggles.tsx b/components/settings/SettingToggles.tsx
index f2c53e3a..9123f3d4 100644
--- a/components/settings/SettingToggles.tsx
+++ b/components/settings/SettingToggles.tsx
@@ -10,6 +10,7 @@ import {
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
+import { getStatistics } from "@/utils/optimize-server";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
@@ -18,7 +19,6 @@ import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import {
- ActivityIndicator,
Linking,
Switch,
TouchableOpacity,
@@ -32,8 +32,6 @@ import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { MediaToggles } from "./MediaToggles";
-import axios from "axios";
-import { getStatistics } from "@/utils/optimize-server";
interface Props extends ViewProps {}
@@ -248,22 +246,6 @@ export const SettingToggles: React.FC = ({ ...props }) => {
-
-
- Use external player (VLC)
-
- Open all videos in VLC instead of the default player. This
- requries VLC to be installed on the phone.
-
-
- {
- updateSettings({ openInVLC: value, forceDirectPlay: value });
- }}
- />
-
-
@@ -334,79 +316,6 @@ export const SettingToggles: React.FC = ({ ...props }) => {
)}
-
-
- Force direct play
-
- This will always request direct play. This is good if you want
- to try to stream movies you think the device supports.
-
-
-
- updateSettings({ forceDirectPlay: value })
- }
- />
-
-
-
-
- Device profile
-
- A profile used for deciding what audio and video codecs the
- device supports.
-
-
-
-
-
- {settings.deviceProfile}
-
-
-
- Profiles
- {
- updateSettings({ deviceProfile: "Expo" });
- }}
- >
- Expo
-
- {
- updateSettings({ deviceProfile: "Native" });
- }}
- >
- Native
-
- {
- updateSettings({ deviceProfile: "Old" });
- }}
- >
- Old
-
-
-
-
-
;
- isPlaying: boolean;
- isSeeking: SharedValue;
- cacheProgress: SharedValue;
- progress: SharedValue;
- isBuffering: boolean;
- showControls: boolean;
- ignoreSafeAreas?: boolean;
- setIgnoreSafeAreas: React.Dispatch>;
- enableTrickplay?: boolean;
- togglePlay: (ticks: number) => void;
- setShowControls: (shown: boolean) => void;
-}
-
-export const Controls: React.FC = ({
- item,
- videoRef,
- togglePlay,
- isPlaying,
- isSeeking,
- progress,
- isBuffering,
- cacheProgress,
- showControls,
- setShowControls,
- ignoreSafeAreas,
- setIgnoreSafeAreas,
- enableTrickplay = true,
-}) => {
- const [settings] = useSettings();
- const router = useRouter();
- const insets = useSafeAreaInsets();
- const { setPlaySettings } = usePlaySettings();
-
- const windowDimensions = Dimensions.get("window");
-
- const { previousItem, nextItem } = useAdjacentItems({ item });
- const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
- item,
- enableTrickplay
- );
-
- 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 { showSkipButton, skipIntro } = useIntroSkipper(
- item.Id,
- currentTime,
- videoRef
- );
-
- const { showSkipCreditButton, skipCredit } = useCreditSkipper(
- item.Id,
- currentTime,
- videoRef
- );
-
- const goToPreviousItem = useCallback(() => {
- if (!previousItem || !settings) return;
-
- const { bitrate, mediaSource, audioIndex, subtitleIndex } =
- getDefaultPlaySettings(previousItem, settings);
-
- setPlaySettings({
- item: previousItem,
- bitrate,
- mediaSource,
- audioIndex,
- subtitleIndex,
- });
-
- router.replace("/play-video");
- }, [previousItem, settings]);
-
- const goToNextItem = useCallback(() => {
- if (!nextItem || !settings) return;
-
- const { bitrate, mediaSource, audioIndex, subtitleIndex } =
- getDefaultPlaySettings(nextItem, settings);
-
- setPlaySettings({
- item: nextItem,
- bitrate,
- mediaSource,
- audioIndex,
- subtitleIndex,
- });
-
- router.replace("/play-video");
- }, [nextItem, settings]);
-
- const updateTimes = useCallback(
- (currentProgress: number, maxValue: number) => {
- const current = ticksToSeconds(currentProgress);
- const remaining = ticksToSeconds(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 = item?.UserData?.PlaybackPositionTicks || 0;
- max.value = item.RunTimeTicks || 0;
- }
- }, [item]);
-
- const toggleControls = () => setShowControls(!showControls);
-
- const handleSliderComplete = useCallback((value: number) => {
- progress.value = value;
- isSeeking.value = false;
- videoRef.current?.seek(Math.max(0, Math.floor(value / 10000000)));
- if (wasPlayingRef.current === true) videoRef.current?.resume();
- }, []);
-
- const handleSliderChange = (value: number) => {
- calculateTrickplayUrl(value);
- };
-
- const handleSliderStart = useCallback(() => {
- if (showControls === false) return;
- wasPlayingRef.current = isPlaying;
- videoRef.current?.pause();
- isSeeking.value = true;
- }, [showControls, isPlaying]);
-
- const handleSkipBackward = useCallback(async () => {
- console.log("handleSkipBackward");
- if (!settings?.rewindSkipTime) return;
- wasPlayingRef.current = isPlaying;
- try {
- const curr = await videoRef.current?.getCurrentPosition();
- if (curr !== undefined) {
- videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
- setTimeout(() => {
- if (wasPlayingRef.current === true) videoRef.current?.resume();
- }, 10);
- }
- } catch (error) {
- writeToLog("ERROR", "Error seeking video backwards", error);
- }
- }, [settings, isPlaying]);
-
- const handleSkipForward = useCallback(async () => {
- console.log("handleSkipForward");
- if (!settings?.forwardSkipTime) return;
- wasPlayingRef.current = isPlaying;
- try {
- const curr = await videoRef.current?.getCurrentPosition();
- if (curr !== undefined) {
- videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
- setTimeout(() => {
- if (wasPlayingRef.current === true) videoRef.current?.resume();
- }, 10);
- }
- } catch (error) {
- writeToLog("ERROR", "Error seeking video forwards", error);
- }
- }, [settings, isPlaying]);
-
- const toggleIgnoreSafeAreas = useCallback(() => {
- setIgnoreSafeAreas((prev) => !prev);
- }, []);
-
- return (
-
-
-
- Skip Intro
-
-
-
-
-
- Skip Credits
-
-
-
- {
- toggleControls();
- }}
- >
-
-
-
-
-
-
-
-
-
-
-
- {
- router.back();
- }}
- className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
- >
-
-
-
-
-
-
- {item?.Name}
- {item?.Type === "Episode" && (
- {item.SeriesName}
- )}
- {item?.Type === "Movie" && (
- {item?.ProductionYear}
- )}
- {item?.Type === "Audio" && (
- {item?.Album}
- )}
-
-
-
-
-
-
-
-
-
- {
- togglePlay(progress.value);
- }}
- >
-
-
-
-
-
-
-
-
-
-
- {
- if (!trickPlayUrl || !trickplayInfo) {
- return null;
- }
- const { x, y, url } = trickPlayUrl;
-
- const tileWidth = 150;
- const tileHeight = 150 / trickplayInfo.aspectRatio!;
- return (
-
-
-
- );
- }}
- sliderHeight={10}
- thumbWidth={0}
- progress={progress}
- minimumValue={min}
- maximumValue={max}
- />
-
-
- {formatTimeString(currentTime)}
-
-
- -{formatTimeString(remainingTime)}
-
-
-
-
-
-
- );
-};
diff --git a/components/video-player/controls/Controls.tsx b/components/video-player/controls/Controls.tsx
new file mode 100644
index 00000000..c8b44a5f
--- /dev/null
+++ b/components/video-player/controls/Controls.tsx
@@ -0,0 +1,594 @@
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+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 { useSettings } from "@/utils/atoms/settings";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import { writeToLog } from "@/utils/log";
+import {
+ formatTimeString,
+ msToTicks,
+ secondsToMs,
+ ticksToMs,
+ ticksToSeconds,
+} from "@/utils/time";
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import { Image } from "expo-image";
+import { useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ 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 {
+ SafeAreaView,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
+import { VideoRef } from "react-native-video";
+import { ControlProvider } from "./contexts/ControlContext";
+import { VideoProvider } from "./contexts/VideoContext";
+import DropdownView from "./DropdownView";
+import * as Haptics from "expo-haptics";
+
+interface Props {
+ item: BaseItemDto;
+ videoRef: React.MutableRefObject;
+ isPlaying: boolean;
+ isSeeking: SharedValue;
+ cacheProgress: SharedValue;
+ progress: SharedValue;
+ isBuffering: boolean;
+ showControls: boolean;
+ ignoreSafeAreas?: boolean;
+ setIgnoreSafeAreas: React.Dispatch>;
+ enableTrickplay?: boolean;
+ togglePlay: (ticks: number) => void;
+ setShowControls: (shown: boolean) => void;
+ offline?: boolean;
+ isVideoLoaded?: boolean;
+ mediaSource?: MediaSourceInfo | null;
+ seek: (ticks: number) => void;
+ play: (() => Promise) | (() => void);
+ pause: () => void;
+ getAudioTracks?: (() => Promise) | (() => TrackInfo[]);
+ getSubtitleTracks?: (() => Promise) | (() => TrackInfo[]);
+ setSubtitleURL?: (url: string, customName: string) => void;
+ setSubtitleTrack?: (index: number) => void;
+ setAudioTrack?: (index: number) => void;
+ stop?: (() => Promise) | (() => void);
+ isVlc?: boolean;
+}
+
+export const Controls: React.FC = ({
+ item,
+ seek,
+ play,
+ pause,
+ togglePlay,
+ isPlaying,
+ isSeeking,
+ progress,
+ isBuffering,
+ cacheProgress,
+ showControls,
+ setShowControls,
+ ignoreSafeAreas,
+ setIgnoreSafeAreas,
+ mediaSource,
+ isVideoLoaded,
+ getAudioTracks,
+ getSubtitleTracks,
+ setSubtitleURL,
+ setSubtitleTrack,
+ setAudioTrack,
+ stop,
+ offline = false,
+ enableTrickplay = true,
+ isVlc = false,
+}) => {
+ const [settings] = useSettings();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+
+ const { previousItem, nextItem } = useAdjacentItems({ item });
+ const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
+ item,
+ !offline && enableTrickplay
+ );
+
+ const [currentTime, setCurrentTime] = useState(0);
+ const [remainingTime, setRemainingTime] = useState(0);
+
+ const min = useSharedValue(0);
+ const max = useSharedValue(item.RunTimeTicks || 0);
+
+ const wasPlayingRef = useRef(false);
+ const lastProgressRef = useRef(0);
+
+ const { showSkipButton, skipIntro } = useIntroSkipper(
+ offline ? undefined : item.Id,
+ currentTime,
+ seek,
+ play,
+ isVlc
+ );
+
+ const { showSkipCreditButton, skipCredit } = useCreditSkipper(
+ offline ? undefined : item.Id,
+ currentTime,
+ seek,
+ play,
+ isVlc
+ );
+
+ const goToPreviousItem = useCallback(() => {
+ if (!previousItem || !settings) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+
+ const { bitrate, mediaSource, audioIndex, subtitleIndex } =
+ getDefaultPlaySettings(previousItem, settings);
+
+ const queryParams = new URLSearchParams({
+ itemId: previousItem.Id ?? "", // Ensure itemId is a string
+ audioIndex: audioIndex?.toString() ?? "",
+ subtitleIndex: subtitleIndex?.toString() ?? "",
+ mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
+ bitrateValue: bitrate.toString(),
+ }).toString();
+
+ if (!bitrate.value) {
+ // @ts-expect-error
+ router.replace(`player/direct-player?${queryParams}`);
+ return;
+ }
+ // @ts-expect-error
+ router.replace(`player/transcoding-player?${queryParams}`);
+ }, [previousItem, settings]);
+
+ const goToNextItem = useCallback(() => {
+ if (!nextItem || !settings) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+
+ const { bitrate, mediaSource, audioIndex, subtitleIndex } =
+ getDefaultPlaySettings(nextItem, settings);
+
+ const queryParams = new URLSearchParams({
+ itemId: nextItem.Id ?? "", // Ensure itemId is a string
+ audioIndex: audioIndex?.toString() ?? "",
+ subtitleIndex: subtitleIndex?.toString() ?? "",
+ mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
+ bitrateValue: bitrate.toString(),
+ }).toString();
+
+ if (!bitrate.value) {
+ // @ts-expect-error
+ router.replace(`player/direct-player?${queryParams}`);
+ return;
+ }
+ // @ts-expect-error
+ router.replace(`player/transcoding-player?${queryParams}`);
+ }, [nextItem, settings]);
+
+ const updateTimes = useCallback(
+ (currentProgress: number, maxValue: number) => {
+ const current = isVlc ? currentProgress : ticksToSeconds(currentProgress);
+ const remaining = isVlc
+ ? maxValue - currentProgress
+ : ticksToSeconds(maxValue - currentProgress);
+
+ setCurrentTime(current);
+ setRemainingTime(remaining);
+
+ // Currently doesm't work in VLC because of some corrupted timestamps, will need to find a workaround.
+ if (currentProgress === maxValue) {
+ setShowControls(true);
+ // Automatically play the next item if it exists
+ goToNextItem();
+ }
+ },
+ [goToNextItem, isVlc]
+ );
+
+ useAnimatedReaction(
+ () => ({
+ progress: progress.value,
+ max: max.value,
+ isSeeking: isSeeking.value,
+ }),
+ (result) => {
+ // console.log("Progress changed", result);
+ if (result.isSeeking === false) {
+ runOnJS(updateTimes)(result.progress, result.max);
+ }
+ },
+ [updateTimes]
+ );
+
+ useEffect(() => {
+ if (item) {
+ progress.value = isVlc
+ ? ticksToMs(item?.UserData?.PlaybackPositionTicks)
+ : item?.UserData?.PlaybackPositionTicks || 0;
+ max.value = isVlc
+ ? ticksToMs(item.RunTimeTicks || 0)
+ : item.RunTimeTicks || 0;
+ }
+ }, [item, isVlc]);
+
+ const toggleControls = () => setShowControls(!showControls);
+
+ const handleSliderComplete = useCallback(
+ async (value: number) => {
+ isSeeking.value = false;
+ progress.value = value;
+
+ await seek(
+ Math.max(0, Math.floor(isVlc ? value : ticksToSeconds(value)))
+ );
+ if (wasPlayingRef.current === true) play();
+ },
+ [isVlc]
+ );
+
+ const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
+
+ const handleSliderChange = (value: number) => {
+ const progressInTicks = isVlc ? msToTicks(value) : value;
+ calculateTrickplayUrl(progressInTicks);
+
+ const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
+ const hours = Math.floor(progressInSeconds / 3600);
+ const minutes = Math.floor((progressInSeconds % 3600) / 60);
+ const seconds = progressInSeconds % 60;
+ setTime({ hours, minutes, seconds });
+ };
+
+ 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;
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ try {
+ const curr = progress.value;
+ if (curr !== undefined) {
+ const newTime = isVlc
+ ? Math.max(0, curr - secondsToMs(settings.rewindSkipTime))
+ : Math.max(0, ticksToSeconds(curr) - settings.rewindSkipTime);
+ await seek(newTime);
+ if (wasPlayingRef.current === true) play();
+ }
+ } catch (error) {
+ writeToLog("ERROR", "Error seeking video backwards", error);
+ }
+ }, [settings, isPlaying, isVlc]);
+
+ const handleSkipForward = useCallback(async () => {
+ if (!settings?.forwardSkipTime) return;
+ wasPlayingRef.current = isPlaying;
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ try {
+ const curr = progress.value;
+ console.log(curr);
+ if (curr !== undefined) {
+ const newTime = isVlc
+ ? curr + secondsToMs(settings.forwardSkipTime)
+ : ticksToSeconds(curr) + settings.forwardSkipTime;
+ await seek(Math.max(0, newTime));
+ if (wasPlayingRef.current === true) play();
+ }
+ } catch (error) {
+ writeToLog("ERROR", "Error seeking video forwards", error);
+ }
+ }, [settings, isPlaying, isVlc]);
+
+ const toggleIgnoreSafeAreas = useCallback(() => {
+ setIgnoreSafeAreas((prev) => !prev);
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ Skip Intro
+
+
+
+
+
+ Skip Credits
+
+
+
+ {
+ toggleControls();
+ }}
+ style={{
+ position: "absolute",
+ width: Dimensions.get("window").width,
+ height: Dimensions.get("window").height,
+ }}
+ >
+
+
+ {Platform.OS !== "ios" && (
+
+
+
+ )}
+ {
+ if (stop) await stop();
+ router.back();
+ }}
+ className="aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2"
+ >
+
+
+
+
+
+
+ {item?.Name}
+ {item?.Type === "Episode" && (
+ {item.SeriesName}
+ )}
+ {item?.Type === "Movie" && (
+ {item?.ProductionYear}
+ )}
+ {item?.Type === "Audio" && (
+ {item?.Album}
+ )}
+
+
+
+
+
+
+
+
+
+ {
+ togglePlay(progress.value);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ {
+ if (!trickPlayUrl || !trickplayInfo) {
+ return null;
+ }
+ const { x, y, url } = trickPlayUrl;
+
+ const tileWidth = 150;
+ const tileHeight = 150 / trickplayInfo.aspectRatio!;
+ return (
+
+
+
+ {`${time.hours > 0 ? `${time.hours}:` : ""}${
+ time.minutes < 10 ? `0${time.minutes}` : time.minutes
+ }:${
+ time.seconds < 10 ? `0${time.seconds}` : time.seconds
+ }`}
+
+
+ );
+ }}
+ sliderHeight={10}
+ thumbWidth={0}
+ progress={progress}
+ minimumValue={min}
+ maximumValue={max}
+ />
+
+
+ {formatTimeString(currentTime, isVlc ? "ms" : "s")}
+
+
+ -{formatTimeString(remainingTime, isVlc ? "ms" : "s")}
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/video-player/controls/DropdownView.tsx b/components/video-player/controls/DropdownView.tsx
new file mode 100644
index 00000000..4621758a
--- /dev/null
+++ b/components/video-player/controls/DropdownView.tsx
@@ -0,0 +1,346 @@
+import React, { useCallback, useMemo, useState } from "react";
+import { View, TouchableOpacity } from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+import * as DropdownMenu from "zeego/dropdown-menu";
+import { useControlContext } from "./contexts/ControlContext";
+import { useVideoContext } from "./contexts/VideoContext";
+import {
+ EmbeddedSubtitle,
+ ExternalSubtitle,
+ TranscodedSubtitle,
+} from "./types";
+import { useAtomValue } from "jotai";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useLocalSearchParams, useRouter } from "expo-router";
+
+interface DropdownViewProps {
+ showControls: boolean;
+}
+
+const DropdownView: React.FC = ({ showControls }) => {
+ const router = useRouter();
+ const api = useAtomValue(apiAtom);
+ const ControlContext = useControlContext();
+ const mediaSource = ControlContext?.mediaSource;
+ const item = ControlContext?.item;
+ const isVideoLoaded = ControlContext?.isVideoLoaded;
+
+ const videoContext = useVideoContext();
+ const {
+ subtitleTracks,
+ audioTracks,
+ setSubtitleURL,
+ setSubtitleTrack,
+ setAudioTrack,
+ } = videoContext;
+
+ const allSubtitleTracksForDirectPlay = useMemo(() => {
+ if (mediaSource?.TranscodingUrl) return null;
+ const embeddedSubs =
+ subtitleTracks
+ ?.map((s) => ({
+ name: s.name,
+ index: s.index,
+ deliveryUrl: undefined,
+ }))
+ .filter((sub) => !sub.name.endsWith("[External]")) || [];
+
+ const externalSubs =
+ mediaSource?.MediaStreams?.filter(
+ (stream) => stream.Type === "Subtitle" && !!stream.DeliveryUrl
+ ).map((s) => ({
+ name: s.DisplayTitle! + " [External]",
+ index: s.Index!,
+ deliveryUrl: s.DeliveryUrl,
+ })) || [];
+
+ // Combine embedded and unique external subs
+ return [...embeddedSubs, ...externalSubs] as (
+ | EmbeddedSubtitle
+ | ExternalSubtitle
+ )[];
+ }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
+
+ const { subtitleIndex, audioIndex, bitrateValue } = useLocalSearchParams<{
+ itemId: string;
+ audioIndex: string;
+ subtitleIndex: string;
+ mediaSourceId: string;
+ bitrateValue: string;
+ }>();
+
+ // Either its on a text subtitle or its on not on any subtitle therefore it should show all the embedded HLS subtitles.
+ const isOnTextSubtitle =
+ mediaSource?.MediaStreams?.find(
+ (x) => x.Index === parseInt(subtitleIndex) && x.IsTextSubtitleStream
+ ) || subtitleIndex === "-1";
+
+ const allSubs =
+ mediaSource?.MediaStreams?.filter((x) => x.Type === "Subtitle") ?? [];
+ const textBasedSubs = allSubs.filter((x) => x.IsTextSubtitleStream);
+
+ // This is used in the case where it is transcoding stream.
+ const chosenSubtitle = textBasedSubs.find(
+ (x) => x.Index === parseInt(subtitleIndex)
+ );
+
+ const intialSubtitleIndex =
+ !bitrateValue || !isOnTextSubtitle
+ ? parseInt(subtitleIndex)
+ : chosenSubtitle && isOnTextSubtitle
+ ? textBasedSubs.indexOf(chosenSubtitle)
+ : -1;
+
+ const [selectedSubtitleIndex, setSelectedSubtitleIndex] =
+ useState(intialSubtitleIndex);
+ const [selectedAudioIndex, setSelectedAudioIndex] = useState(
+ parseInt(audioIndex)
+ );
+
+ // TODO: Need to account for the fact when user is on text-based subtitle at start.
+ // Then the user swaps to another text based subtitle.
+ // Then changes audio track.
+ // The user will have the first text based subtitle selected still but it should be the second text based subtitle.
+ const allSubtitleTracksForTranscodingStream = useMemo(() => {
+ const disableSubtitle = {
+ name: "Disable",
+ index: -1,
+ IsTextSubtitleStream: true,
+ } as TranscodedSubtitle;
+ if (isOnTextSubtitle) {
+ const textSubtitles =
+ subtitleTracks?.map((s) => ({
+ name: s.name,
+ index: s.index,
+ IsTextSubtitleStream: true,
+ })) || [];
+
+ const imageSubtitles = allSubs
+ .filter((x) => !x.IsTextSubtitleStream)
+ .map(
+ (x) =>
+ ({
+ name: x.DisplayTitle!,
+ index: x.Index!,
+ IsTextSubtitleStream: x.IsTextSubtitleStream,
+ } as TranscodedSubtitle)
+ );
+
+ const textSubtitlesMap = new Map(textSubtitles.map((s) => [s.name, s]));
+ const imageSubtitlesMap = new Map(imageSubtitles.map((s) => [s.name, s]));
+
+ const sortedSubtitles = Array.from(
+ new Set(
+ allSubs
+ .map((sub) => {
+ const displayTitle = sub.DisplayTitle ?? "";
+ if (textSubtitlesMap.has(displayTitle)) {
+ return textSubtitlesMap.get(displayTitle);
+ }
+ if (imageSubtitlesMap.has(displayTitle)) {
+ return imageSubtitlesMap.get(displayTitle);
+ }
+ return null;
+ })
+ .filter(
+ (subtitle): subtitle is TranscodedSubtitle => subtitle !== null
+ )
+ )
+ );
+
+ return [disableSubtitle, ...sortedSubtitles];
+ }
+
+ const transcodedSubtitle: TranscodedSubtitle[] = allSubs.map((x) => ({
+ name: x.DisplayTitle!,
+ index: x.Index!,
+ IsTextSubtitleStream: x.IsTextSubtitleStream!,
+ }));
+
+ return [disableSubtitle, ...transcodedSubtitle];
+ }, [item, isVideoLoaded, subtitleTracks, mediaSource?.MediaStreams]);
+
+ const ChangeTranscodingSubtitle = useCallback(
+ (subtitleIndex: number) => {
+ const queryParams = new URLSearchParams({
+ itemId: item.Id ?? "", // Ensure itemId is a string
+ audioIndex: audioIndex?.toString() ?? "",
+ subtitleIndex: subtitleIndex?.toString() ?? "",
+ mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
+ bitrateValue: bitrateValue,
+ }).toString();
+
+ // @ts-expect-error
+ router.replace(`player/transcoding-player?${queryParams}`);
+ },
+ [mediaSource]
+ );
+
+ // Audio tracks for transcoding streams.
+ const allAudio =
+ mediaSource?.MediaStreams?.filter((x) => x.Type === "Audio").map((x) => ({
+ name: x.DisplayTitle!,
+ index: x.Index!,
+ })) || [];
+ const ChangeTranscodingAudio = useCallback(
+ (audioIndex: number) => {
+ const queryParams = new URLSearchParams({
+ itemId: item.Id ?? "", // Ensure itemId is a string
+ audioIndex: audioIndex?.toString() ?? "",
+ subtitleIndex: subtitleIndex,
+ mediaSourceId: mediaSource?.Id ?? "", // Ensure mediaSourceId is a string
+ bitrateValue: bitrateValue,
+ }).toString();
+
+ // @ts-expect-error
+ router.replace(`player/transcoding-player?${queryParams}`);
+ },
+ [mediaSource]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Subtitle
+
+
+ {!mediaSource?.TranscodingUrl &&
+ allSubtitleTracksForDirectPlay?.map((sub, idx: number) => (
+ {
+ if ("deliveryUrl" in sub && sub.deliveryUrl) {
+ setSubtitleURL &&
+ setSubtitleURL(
+ api?.basePath + sub.deliveryUrl,
+ sub.name
+ );
+
+ console.log(
+ "Set external subtitle: ",
+ api?.basePath + sub.deliveryUrl
+ );
+ } else {
+ console.log("Set sub index: ", sub.index);
+ setSubtitleTrack && setSubtitleTrack(sub.index);
+ }
+
+ setSelectedSubtitleIndex(sub.index);
+ console.log("Subtitle: ", sub);
+ }}
+ >
+
+ {sub.name}
+
+
+ ))}
+ {mediaSource?.TranscodingUrl &&
+ allSubtitleTracksForTranscodingStream?.map(
+ (sub, idx: number) => (
+ {
+ console.log("Set sub index: ", sub.index);
+ if (selectedSubtitleIndex === sub?.index.toString())
+ return;
+ setSelectedSubtitleIndex(sub.index);
+ if (sub.IsTextSubtitleStream && isOnTextSubtitle) {
+ setSubtitleTrack && setSubtitleTrack(sub.index);
+ return;
+ }
+
+ ChangeTranscodingSubtitle(sub.index);
+ }}
+ >
+
+ {sub.name}
+
+
+ )
+ )}
+
+
+
+
+ Audio
+
+
+ {!mediaSource?.TranscodingUrl &&
+ audioTracks?.map((track, idx: number) => (
+ {
+ setSelectedAudioIndex(track.index);
+ setAudioTrack && setAudioTrack(track.index);
+ }}
+ >
+
+ {track.name}
+
+
+ ))}
+ {mediaSource?.TranscodingUrl &&
+ allAudio?.map((track, idx: number) => (
+ {
+ if (selectedAudioIndex === track.index.toString()) return;
+ setSelectedAudioIndex(track.index);
+ ChangeTranscodingAudio(track.index);
+ }}
+ >
+
+ {track.name}
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default DropdownView;
diff --git a/components/video-player/controls/SliderScrubbter.tsx b/components/video-player/controls/SliderScrubbter.tsx
new file mode 100644
index 00000000..a618a350
--- /dev/null
+++ b/components/video-player/controls/SliderScrubbter.tsx
@@ -0,0 +1,144 @@
+import { useTrickplay } from '@/hooks/useTrickplay';
+import { formatTimeString, msToTicks, ticksToSeconds } from '@/utils/time';
+import React, { useRef, useState } from 'react';
+import { View, Text } from 'react-native';
+import { Image } from "expo-image";
+import { Slider } from "react-native-awesome-slider";
+import { SharedValue, useSharedValue } from 'react-native-reanimated';
+import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
+
+interface SliderScrubberProps {
+ cacheProgress: SharedValue;
+ handleSliderStart: () => void;
+ handleSliderComplete: (value: number) => void;
+ progress: SharedValue;
+ min: SharedValue;
+ max: SharedValue;
+ currentTime: number;
+ remainingTime: number;
+ item: BaseItemDto;
+}
+
+const SliderScrubber: React.FC = ({
+ cacheProgress,
+ handleSliderStart,
+ handleSliderComplete,
+ progress,
+ min,
+ max,
+ currentTime,
+ remainingTime,
+ item,
+}) => {
+
+
+ const [time, setTime] = useState({ hours: 0, minutes: 0, seconds: 0 });
+ const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } = useTrickplay(
+ item,
+ );
+
+ const handleSliderChange = (value: number) => {
+ const progressInTicks = msToTicks(value);
+ calculateTrickplayUrl(progressInTicks);
+
+ const progressInSeconds = Math.floor(ticksToSeconds(progressInTicks));
+ const hours = Math.floor(progressInSeconds / 3600);
+ const minutes = Math.floor((progressInSeconds % 3600) / 60);
+ const seconds = progressInSeconds % 60;
+ setTime({ hours, minutes, seconds });
+ };
+
+ return (
+
+ {
+ if (!trickPlayUrl || !trickplayInfo) {
+ return null;
+ }
+ const { x, y, url } = trickPlayUrl;
+
+ const tileWidth = 150;
+ const tileHeight = 150 / trickplayInfo.aspectRatio!;
+ return (
+
+
+
+ {`${time.hours > 0 ? `${time.hours}:` : ""}${
+ time.minutes < 10 ? `0${time.minutes}` : time.minutes
+ }:${
+ time.seconds < 10 ? `0${time.seconds}` : time.seconds
+ }`}
+
+
+ );
+ }}
+ sliderHeight={10}
+ thumbWidth={0}
+ progress={progress}
+ minimumValue={min}
+ maximumValue={max}
+ />
+
+
+ {formatTimeString(currentTime, "ms")}
+
+
+ -{formatTimeString(remainingTime, "ms")}
+
+
+
+ );
+};
+
+export default SliderScrubber;
\ No newline at end of file
diff --git a/components/video-player/controls/contexts/ControlContext.tsx b/components/video-player/controls/contexts/ControlContext.tsx
new file mode 100644
index 00000000..4d2a8df4
--- /dev/null
+++ b/components/video-player/controls/contexts/ControlContext.tsx
@@ -0,0 +1,34 @@
+import { TrackInfo } from '@/modules/vlc-player';
+import { BaseItemDto, MediaSourceInfo } from '@jellyfin/sdk/lib/generated-client';
+import React, { createContext, useContext, useState, ReactNode } from 'react';
+
+interface ControlContextProps {
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean | undefined;
+}
+
+const ControlContext = createContext(undefined);
+
+interface ControlProviderProps {
+ children: ReactNode;
+ item: BaseItemDto;
+ mediaSource: MediaSourceInfo | null | undefined;
+ isVideoLoaded: boolean | undefined;
+}
+
+export const ControlProvider: React.FC = ({ children, item, mediaSource, isVideoLoaded }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const useControlContext = () => {
+ const context = useContext(ControlContext);
+ if (context === undefined) {
+ throw new Error('useControlContext must be used within a ControlProvider');
+ }
+ return context;
+};
\ No newline at end of file
diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx
new file mode 100644
index 00000000..7f7b30c1
--- /dev/null
+++ b/components/video-player/controls/contexts/VideoContext.tsx
@@ -0,0 +1,98 @@
+import { TrackInfo } from "@/modules/vlc-player";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import React, {
+ createContext,
+ useContext,
+ useState,
+ ReactNode,
+ useEffect,
+} from "react";
+import { useControlContext } from "./ControlContext";
+
+interface VideoContextProps {
+ audioTracks: TrackInfo[] | null;
+ subtitleTracks: TrackInfo[] | null;
+ setAudioTrack: ((index: number) => void) | undefined;
+ setSubtitleTrack: ((index: number) => void) | undefined;
+ setSubtitleURL: ((url: string, customName: string) => void) | undefined;
+}
+
+const VideoContext = createContext(undefined);
+
+interface VideoProviderProps {
+ children: ReactNode;
+ getAudioTracks:
+ | (() => Promise)
+ | (() => TrackInfo[])
+ | undefined;
+ getSubtitleTracks:
+ | (() => Promise)
+ | (() => TrackInfo[])
+ | undefined;
+ setAudioTrack: ((index: number) => void) | undefined;
+ setSubtitleTrack: ((index: number) => void) | undefined;
+ setSubtitleURL: ((url: string, customName: string) => void) | undefined;
+}
+
+export const VideoProvider: React.FC = ({
+ children,
+ getSubtitleTracks,
+ getAudioTracks,
+ setSubtitleTrack,
+ setSubtitleURL,
+ setAudioTrack,
+}) => {
+ const [audioTracks, setAudioTracks] = useState(null);
+ const [subtitleTracks, setSubtitleTracks] = useState(
+ null
+ );
+
+ const ControlContext = useControlContext();
+ const isVideoLoaded = ControlContext?.isVideoLoaded;
+
+ useEffect(() => {
+ const fetchTracks = async () => {
+ if (
+ getSubtitleTracks &&
+ (subtitleTracks === null || subtitleTracks.length === 0)
+ ) {
+ const subtitles = await getSubtitleTracks();
+ console.log("Getting embeded subtitles...", subtitles);
+ setSubtitleTracks(subtitles);
+ }
+ if (
+ getAudioTracks &&
+ (audioTracks === null || audioTracks.length === 0)
+ ) {
+ const audio = await getAudioTracks();
+ setAudioTracks(audio);
+ }
+ };
+ fetchTracks();
+ }, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useVideoContext = () => {
+ const context = useContext(VideoContext);
+ if (context === undefined) {
+ throw new Error("useVideoContext must be used within a VideoProvider");
+ }
+ return context;
+};
diff --git a/components/video-player/controls/types.ts b/components/video-player/controls/types.ts
new file mode 100644
index 00000000..b66ecd8e
--- /dev/null
+++ b/components/video-player/controls/types.ts
@@ -0,0 +1,20 @@
+type EmbeddedSubtitle = {
+ name: string;
+ index: number;
+ isExternal: boolean;
+};
+
+type ExternalSubtitle = {
+ name: string;
+ index: number;
+ isExternal: boolean;
+ deliveryUrl: string;
+};
+
+type TranscodedSubtitle = {
+ name: string;
+ index: number;
+ IsTextSubtitleStream: boolean;
+}
+
+export { EmbeddedSubtitle, ExternalSubtitle, TranscodedSubtitle };
\ No newline at end of file
diff --git a/components/vlc/VideoDebugInfo.tsx b/components/vlc/VideoDebugInfo.tsx
new file mode 100644
index 00000000..5ae04517
--- /dev/null
+++ b/components/vlc/VideoDebugInfo.tsx
@@ -0,0 +1,73 @@
+import {
+ TrackInfo,
+ VlcPlayerViewRef,
+} from "@/modules/vlc-player/src/VlcPlayer.types";
+import React, { useEffect, useState } from "react";
+import { TouchableOpacity, View, ViewProps } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Text } from "../common/Text";
+
+interface Props extends ViewProps {
+ playerRef: React.RefObject;
+}
+
+export const VideoDebugInfo: React.FC = ({ playerRef, ...props }) => {
+ const [audioTracks, setAudioTracks] = useState(null);
+ const [subtitleTracks, setSubtitleTracks] = useState(
+ 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]);
+
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ Playback State:
+ Audio Tracks:
+ {audioTracks &&
+ audioTracks.map((track, index) => (
+
+ {track.name} (Index: {track.index})
+
+ ))}
+ Subtitle Tracks:
+ {subtitleTracks &&
+ subtitleTracks.map((track, index) => (
+
+ {track.name} (Index: {track.index})
+
+ ))}
+ {
+ if (playerRef.current) {
+ playerRef.current.getAudioTracks().then(setAudioTracks);
+ playerRef.current.getSubtitleTracks().then(setSubtitleTracks);
+ }
+ }}
+ >
+ Refresh Tracks
+
+
+ );
+};
diff --git a/eas.json b/eas.json
index 6c42b511..336a9d1b 100644
--- a/eas.json
+++ b/eas.json
@@ -22,13 +22,13 @@
}
},
"production": {
- "channel": "0.18.0",
+ "channel": "0.21.0",
"android": {
"image": "latest"
}
},
"production-apk": {
- "channel": "0.18.0",
+ "channel": "0.21.0",
"android": {
"buildType": "apk",
"image": "latest"
diff --git a/edge-to-edge-fix.patch b/edge-to-edge-fix.patch
new file mode 100644
index 00000000..184ee948
--- /dev/null
+++ b/edge-to-edge-fix.patch
@@ -0,0 +1,15 @@
+--- expo.js.original 2024-11-10 09:08:19
++++ node_modules/react-native-edge-to-edge/dist/commonjs/expo.js 2024-11-10 09:08:23
+@@ -19,10 +19,8 @@
+ const {
+ barStyle
+ } = androidStatusBar;
++ const android = props?.android || {};
+ const {
+- android = {}
+- } = props;
+- const {
+ parentTheme = "Default"
+ } = android;
+ config.modResults.resources.style = config.modResults.resources.style?.map(style => {
+\ No newline at end of file
diff --git a/hooks/useAdjacentEpisodes.ts b/hooks/useAdjacentEpisodes.ts
index 90b50b21..b05ea591 100644
--- a/hooks/useAdjacentEpisodes.ts
+++ b/hooks/useAdjacentEpisodes.ts
@@ -18,7 +18,6 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
const parentId = item?.AlbumId || item?.ParentId;
const indexNumber = item?.IndexNumber;
- console.log("Getting previous item for " + indexNumber);
if (
!api ||
!parentId ||
@@ -26,12 +25,6 @@ export const useAdjacentItems = ({ item }: AdjacentEpisodesProps) => {
indexNumber === null ||
indexNumber - 1 < 1
) {
- console.log("No previous item", {
- itemIndex: indexNumber,
- itemId: item?.Id,
- parentId: parentId,
- indexNumber: indexNumber,
- });
return null;
}
diff --git a/hooks/useAndroidNavigationBar.ts b/hooks/useAndroidNavigationBar.ts
deleted file mode 100644
index d8c55802..00000000
--- a/hooks/useAndroidNavigationBar.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as NavigationBar from "expo-navigation-bar";
-import { useEffect } from "react";
-import { Platform } from "react-native";
-
-export const useAndroidNavigationBar = () => {
- useEffect(() => {
- if (Platform.OS === "android") {
- NavigationBar.setVisibilityAsync("hidden");
- NavigationBar.setBehaviorAsync("overlay-swipe");
-
- return () => {
- NavigationBar.setVisibilityAsync("visible");
- NavigationBar.setBehaviorAsync("inset-swipe");
- };
- }
- }, []);
-};
diff --git a/hooks/useCreditSkipper.ts b/hooks/useCreditSkipper.ts
index e7d71ba6..5f7a8b34 100644
--- a/hooks/useCreditSkipper.ts
+++ b/hooks/useCreditSkipper.ts
@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
+import { msToSeconds, secondsToMs } from "@/utils/time";
interface CreditTimestamps {
Introduction: {
@@ -21,16 +22,29 @@ interface CreditTimestamps {
export const useCreditSkipper = (
itemId: string | undefined,
currentTime: number,
- videoRef: React.RefObject
+ seek: (time: number) => void,
+ play: () => void,
+ isVlc: boolean = false
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
+ if (isVlc) {
+ currentTime = msToSeconds(currentTime);
+ }
+
+ const wrappedSeek = (seconds: number) => {
+ if (isVlc) {
+ seek(secondsToMs(seconds));
+ return;
+ }
+ seek(seconds);
+ };
+
const { data: creditTimestamps } = useQuery({
queryKey: ["creditTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
- console.log("No item id");
return null;
}
@@ -61,17 +75,17 @@ export const useCreditSkipper = (
}, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => {
- console.log("skipCredits");
- if (!creditTimestamps || !videoRef.current) return;
+ if (!creditTimestamps) return;
+ console.log(`Skipping credits to ${creditTimestamps.Credits.End}`);
try {
- videoRef.current.seek(creditTimestamps.Credits.End);
+ wrappedSeek(creditTimestamps.Credits.End);
setTimeout(() => {
- videoRef.current?.resume();
+ play();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
- }, [creditTimestamps, videoRef]);
+ }, [creditTimestamps]);
return { showSkipCreditButton, skipCredit };
};
diff --git a/hooks/useDefaultPlaySettings.ts b/hooks/useDefaultPlaySettings.ts
new file mode 100644
index 00000000..d1da0dae
--- /dev/null
+++ b/hooks/useDefaultPlaySettings.ts
@@ -0,0 +1,54 @@
+import { Bitrate, BITRATES } from "@/components/BitrateSelector";
+import { Settings } from "@/utils/atoms/settings";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client";
+import { useMemo } from "react";
+
+const useDefaultPlaySettings = (
+ item: BaseItemDto,
+ settings: Settings | null
+) => {
+ const playSettings = useMemo(() => {
+ // 1. Get first media source
+ const mediaSource = item.MediaSources?.[0];
+
+ // 2. Get default or preferred audio
+ const defaultAudioIndex = mediaSource?.DefaultAudioStreamIndex;
+ const preferedAudioIndex = mediaSource?.MediaStreams?.find(
+ (x) => x.Language === settings?.defaultAudioLanguage
+ )?.Index;
+ const firstAudioIndex = mediaSource?.MediaStreams?.find(
+ (x) => x.Type === "Audio"
+ )?.Index;
+
+ // 3. Get default or preferred subtitle
+ const preferedSubtitleIndex = mediaSource?.MediaStreams?.find(
+ (x) => x.Language === settings?.defaultSubtitleLanguage?.value
+ )?.Index;
+ const defaultSubtitleIndex = mediaSource?.MediaStreams?.find(
+ (stream) => stream.Type === "Subtitle" && stream.IsDefault
+ )?.Index;
+
+ // 4. Get default bitrate
+ const bitrate = BITRATES[0];
+
+ return {
+ defaultAudioIndex:
+ preferedAudioIndex || defaultAudioIndex || firstAudioIndex || undefined,
+ defaultSubtitleIndex:
+ preferedSubtitleIndex || defaultSubtitleIndex || undefined,
+ defaultMediaSource: mediaSource || undefined,
+ defaultBitrate: bitrate || undefined,
+ };
+ }, [
+ item.MediaSources,
+ settings?.defaultAudioLanguage,
+ settings?.defaultSubtitleLanguage,
+ ]);
+
+ return playSettings;
+};
+
+export default useDefaultPlaySettings;
diff --git a/hooks/useDownloadedFileOpener.ts b/hooks/useDownloadedFileOpener.ts
index 29aa7c56..4c630710 100644
--- a/hooks/useDownloadedFileOpener.ts
+++ b/hooks/useDownloadedFileOpener.ts
@@ -1,5 +1,3 @@
-// hooks/useFileOpener.ts
-
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
import { writeToLog } from "@/utils/log";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
@@ -7,46 +5,44 @@ import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useCallback } from "react";
-export const useFileOpener = () => {
+export const getDownloadedFileUrl = async (itemId: string): Promise => {
+ const directory = FileSystem.documentDirectory;
+
+ if (!directory) {
+ throw new Error("Document directory is not available");
+ }
+
+ if (!itemId) {
+ throw new Error("Item ID is not available");
+ }
+
+ const files = await FileSystem.readDirectoryAsync(directory);
+ const path = itemId!;
+ const matchingFile = files.find((file) => file.startsWith(path));
+
+ if (!matchingFile) {
+ throw new Error(`No file found for item ${path}`);
+ }
+
+ return `${directory}${matchingFile}`;
+};
+
+export const useDownloadedFileOpener = () => {
const router = useRouter();
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
- const openFile = useCallback(async (item: BaseItemDto) => {
- const directory = FileSystem.documentDirectory;
-
- if (!directory) {
- throw new Error("Document directory is not available");
- }
-
- if (!item.Id) {
- throw new Error("Item ID is not available");
- }
-
- try {
- const files = await FileSystem.readDirectoryAsync(directory);
- for (let f of files) {
- console.log(f);
+ const openFile = useCallback(
+ async (item: BaseItemDto) => {
+ try {
+ // @ts-expect-error
+ router.push("/player/direct-player?offline=true&itemId=" + item.Id);
+ } catch (error) {
+ writeToLog("ERROR", "Error opening file", error);
+ console.error("Error opening file:", error);
}
- const path = item.Id!;
- const matchingFile = files.find((file) => file.startsWith(path));
-
- if (!matchingFile) {
- throw new Error(`No file found for item ${path}`);
- }
-
- const url = `${directory}${matchingFile}`;
-
- setOfflineSettings({
- item,
- });
- setPlayUrl(url);
-
- router.push("/play-offline-video");
- } catch (error) {
- writeToLog("ERROR", "Error opening file", error);
- console.error("Error opening file:", error);
- }
- }, []);
+ },
+ [setOfflineSettings, setPlayUrl, router]
+ );
return { openFile };
};
diff --git a/hooks/useImageColors.ts b/hooks/useImageColors.ts
index 57fb6c80..c7250c86 100644
--- a/hooks/useImageColors.ts
+++ b/hooks/useImageColors.ts
@@ -54,7 +54,6 @@ export const useImageColors = ({
// If colors are cached, use them and exit
if (_primary && _text) {
- console.info("useImageColors ~ Using cached colors for performance.");
setPrimaryColor({
primary: _primary,
text: _text,
diff --git a/hooks/useImageStorage.ts b/hooks/useImageStorage.ts
index ad271f07..f379de1c 100644
--- a/hooks/useImageStorage.ts
+++ b/hooks/useImageStorage.ts
@@ -1,14 +1,11 @@
-import { useState, useCallback } from "react";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import * as FileSystem from "expo-file-system";
import { storage } from "@/utils/mmkv";
+import { useCallback } from "react";
const useImageStorage = () => {
const saveBase64Image = useCallback(async (base64: string, key: string) => {
try {
- // Save the base64 string to AsyncStorage
+ // Save the base64 string to storage
storage.set(key, base64);
- console.log("Image saved successfully");
} catch (error) {
console.error("Error saving image:", error);
throw error;
@@ -70,7 +67,7 @@ const useImageStorage = () => {
const loadImage = useCallback(async (key: string) => {
try {
- // Retrieve the base64 string from AsyncStorage
+ // Retrieve the base64 string from storage
const base64Image = storage.getString(key);
if (base64Image !== null) {
// Set the loaded image state
diff --git a/hooks/useIntroSkipper.ts b/hooks/useIntroSkipper.ts
index 43d1699a..0cf15818 100644
--- a/hooks/useIntroSkipper.ts
+++ b/hooks/useIntroSkipper.ts
@@ -4,6 +4,7 @@ import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
+import { msToSeconds, secondsToMs } from "@/utils/time";
interface IntroTimestamps {
EpisodeId: string;
@@ -14,19 +15,36 @@ interface IntroTimestamps {
Valid: boolean;
}
+/**
+ * Custom hook to handle skipping intros in a media player.
+ *
+ * @param {number} currentTime - The current playback time in seconds.
+ */
export const useIntroSkipper = (
itemId: string | undefined,
currentTime: number,
- videoRef: React.RefObject
+ seek: (ticks: number) => void,
+ play: () => void,
+ isVlc: boolean = false
) => {
const [api] = useAtom(apiAtom);
const [showSkipButton, setShowSkipButton] = useState(false);
+ if (isVlc) {
+ currentTime = msToSeconds(currentTime);
+ }
+
+ const wrappedSeek = (seconds: number) => {
+ if (isVlc) {
+ seek(secondsToMs(seconds));
+ return;
+ }
+ seek(seconds);
+ };
const { data: introTimestamps } = useQuery({
queryKey: ["introTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
- console.log("No item id");
return null;
}
@@ -58,16 +76,16 @@ export const useIntroSkipper = (
const skipIntro = useCallback(() => {
console.log("skipIntro");
- if (!introTimestamps || !videoRef.current) return;
+ if (!introTimestamps) return;
try {
- videoRef.current.seek(introTimestamps.IntroEnd);
+ wrappedSeek(introTimestamps.IntroEnd);
setTimeout(() => {
- videoRef.current?.resume();
+ play();
}, 200);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
- }, [introTimestamps, videoRef]);
+ }, [introTimestamps]);
return { showSkipButton, skipIntro };
};
diff --git a/hooks/useNavigationBarVisibility.ts b/hooks/useNavigationBarVisibility.ts
deleted file mode 100644
index 5af3e5d7..00000000
--- a/hooks/useNavigationBarVisibility.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-// hooks/useNavigationBarVisibility.ts
-
-import { useEffect } from "react";
-import { Platform } from "react-native";
-import * as NavigationBar from "expo-navigation-bar";
-
-export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
- useEffect(() => {
- const handleVisibility = async () => {
- if (Platform.OS === "android") {
- if (isPlaying) {
- await NavigationBar.setVisibilityAsync("hidden");
- } else {
- await NavigationBar.setVisibilityAsync("visible");
- }
- }
- };
-
- handleVisibility();
-
- return () => {
- if (Platform.OS === "android") {
- NavigationBar.setVisibilityAsync("visible");
- }
- };
- }, [isPlaying]);
-};
diff --git a/hooks/useOrientation.ts b/hooks/useOrientation.ts
index bfca1aab..1ecb31ac 100644
--- a/hooks/useOrientation.ts
+++ b/hooks/useOrientation.ts
@@ -24,5 +24,5 @@ export const useOrientation = () => {
};
}, []);
- return { orientation };
+ return { orientation, setOrientation };
};
diff --git a/hooks/useRemuxHlsToMp4.ts b/hooks/useRemuxHlsToMp4.ts
index 785e4f08..d2e5388b 100644
--- a/hooks/useRemuxHlsToMp4.ts
+++ b/hooks/useRemuxHlsToMp4.ts
@@ -1,18 +1,19 @@
-import { useCallback } from "react";
-import { useAtom, useAtomValue } from "jotai";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import * as FileSystem from "expo-file-system";
-import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { writeToLog } from "@/utils/log";
-import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner-native";
import { useDownload } from "@/providers/DownloadProvider";
-import { useRouter } from "expo-router";
-import { JobStatus } from "@/utils/optimize-server";
-import useImageStorage from "./useImageStorage";
-import { getItemImage } from "@/utils/getItemImage";
import { apiAtom } from "@/providers/JellyfinProvider";
+import { getItemImage } from "@/utils/getItemImage";
+import { writeToLog } from "@/utils/log";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { useQueryClient } from "@tanstack/react-query";
+import * as FileSystem from "expo-file-system";
+import { useRouter } from "expo-router";
+import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
+import { useAtomValue } from "jotai";
+import { useCallback } from "react";
+import { toast } from "sonner-native";
+import useImageStorage from "./useImageStorage";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -21,23 +22,16 @@ import { apiAtom } from "@/providers/JellyfinProvider";
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
-export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
+export const useRemuxHlsToMp4 = () => {
const api = useAtomValue(apiAtom);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
- const { loadImage, saveImage, image2Base64, saveBase64Image } =
- useImageStorage();
-
- if (!item.Id || !item.Name) {
- writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
- throw new Error("Item must have an Id and Name");
- }
-
- const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
+ const { saveImage } = useImageStorage();
const startRemuxing = useCallback(
- async (url: string) => {
+ async (item: BaseItemDto, url: string, mediaSource: MediaSourceInfo) => {
+ const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
if (!api) throw new Error("API is not defined");
if (!item.Id) throw new Error("Item must have an Id");
@@ -75,13 +69,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
id: "",
deviceId: "",
inputUrl: "",
- item,
- itemId: item.Id,
+ item: item,
+ itemId: item.Id!,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
- } as JobStatus,
+ },
]);
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
@@ -135,7 +129,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
- reject(new Error("Remuxing failed")); // Reject the promise on error
+ reject(new Error("Remuxing failed"));
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
@@ -164,15 +158,13 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
throw error; // Re-throw the error to propagate it to the caller
}
},
- [output, item]
+ []
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
- setProcesses((prev) => {
- return prev.filter((process) => process.itemId !== item.Id);
- });
- }, [item.Name]);
+ setProcesses([]);
+ }, []);
return { startRemuxing, cancelRemuxing };
};
diff --git a/hooks/useRevalidatePlaybackProgressCache.ts b/hooks/useRevalidatePlaybackProgressCache.ts
new file mode 100644
index 00000000..f939502b
--- /dev/null
+++ b/hooks/useRevalidatePlaybackProgressCache.ts
@@ -0,0 +1,29 @@
+import { useQueryClient } from "@tanstack/react-query";
+
+/**
+ * useRevalidatePlaybackProgressCache invalidates queries related to playback progress.
+ */
+export function useRevalidatePlaybackProgressCache() {
+ const queryClient = useQueryClient();
+
+ const revalidate = async () => {
+ // List of all the queries to invalidate
+ const queriesToInvalidate = [
+ ["item"],
+ ["resumeItems"],
+ ["continueWatching"],
+ ["nextUp-all"],
+ ["nextUp"],
+ ["episodes"],
+ ["seasons"],
+ ["home"],
+ ];
+
+ // Invalidate each query
+ for (const queryKey of queriesToInvalidate) {
+ await queryClient.invalidateQueries({ queryKey });
+ }
+ };
+
+ return revalidate;
+}
diff --git a/hooks/useTrickplay.ts b/hooks/useTrickplay.ts
index 6033420b..960aee62 100644
--- a/hooks/useTrickplay.ts
+++ b/hooks/useTrickplay.ts
@@ -1,6 +1,7 @@
// hooks/useTrickplay.ts
import { apiAtom } from "@/providers/JellyfinProvider";
+import { ticksToMs } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -57,6 +58,7 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
: null;
}, [item, enabled]);
+ // Takes in ticks.
const calculateTrickplayUrl = useCallback(
(progress: number) => {
if (!enabled) {
@@ -74,28 +76,33 @@ export const useTrickplay = (item: BaseItemDto, enabled = true) => {
}
const { data, resolution } = trickplayInfo;
- const { Interval, TileWidth, TileHeight } = data;
+ const { Interval, TileWidth, TileHeight, Width, Height } = data;
- if (!Interval || !TileWidth || !TileHeight || !resolution) {
+ if (
+ !Interval ||
+ !TileWidth ||
+ !TileHeight ||
+ !resolution ||
+ !Width ||
+ !Height
+ ) {
throw new Error("Invalid trickplay data");
}
- const currentSecond = Math.max(0, Math.floor(progress / 10000000));
+ const currentTimeMs = Math.max(0, ticksToMs(progress));
+ const currentTile = Math.floor(currentTimeMs / Interval);
- const cols = TileWidth;
- const rows = TileHeight;
- const imagesPerTile = cols * rows;
- const imageIndex = Math.floor(currentSecond / (Interval / 1000));
- const tileIndex = Math.floor(imageIndex / imagesPerTile);
+ const tileSize = TileWidth * TileHeight;
+ const tileOffset = currentTile % tileSize;
+ const index = Math.floor(currentTile / tileSize);
- const positionInTile = imageIndex % imagesPerTile;
- const rowInTile = Math.floor(positionInTile / cols);
- const colInTile = positionInTile % cols;
+ const tileOffsetX = tileOffset % TileWidth;
+ const tileOffsetY = Math.floor(tileOffset / TileWidth);
const newTrickPlayUrl = {
- x: rowInTile,
- y: colInTile,
- url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
+ x: tileOffsetX,
+ y: tileOffsetY,
+ url: `${api.basePath}/Videos/${item.Id}/Trickplay/${resolution}/${index}.jpg?api_key=${api.accessToken}`,
};
setTrickPlayUrl(newTrickPlayUrl);
diff --git a/hooks/useWebsockets.ts b/hooks/useWebsockets.ts
index 92c647bb..473106d1 100644
--- a/hooks/useWebsockets.ts
+++ b/hooks/useWebsockets.ts
@@ -15,6 +15,7 @@ interface UseWebSocketProps {
pauseVideo: () => void;
playVideo: () => void;
stopPlayback: () => void;
+ offline?: boolean;
}
export const useWebSocket = ({
@@ -22,6 +23,7 @@ export const useWebSocket = ({
pauseVideo,
playVideo,
stopPlayback,
+ offline = false,
}: UseWebSocketProps) => {
const router = useRouter();
const user = useAtomValue(userAtom);
@@ -38,7 +40,7 @@ export const useWebSocket = ({
});
useEffect(() => {
- if (!deviceId || !api?.accessToken) return;
+ if (offline || !deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
@@ -80,10 +82,10 @@ export const useWebSocket = ({
}
newWebSocket.close();
};
- }, [api, deviceId, user]);
+ }, [api, deviceId, user, offline]);
useEffect(() => {
- if (!ws) return;
+ if (offline || !ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
@@ -106,7 +108,7 @@ export const useWebSocket = ({
Alert.alert("Message from server: " + title, body);
}
};
- }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router]);
+ }, [ws, stopPlayback, playVideo, pauseVideo, isPlaying, router, offline]);
return { isConnected };
};
diff --git a/modules/vlc-player/android/.gradle/8.9/checksums/checksums.lock b/modules/vlc-player/android/.gradle/8.9/checksums/checksums.lock
new file mode 100644
index 00000000..52a7f0f4
Binary files /dev/null and b/modules/vlc-player/android/.gradle/8.9/checksums/checksums.lock differ
diff --git a/modules/vlc-player/android/.gradle/8.9/dependencies-accessors/gc.properties b/modules/vlc-player/android/.gradle/8.9/dependencies-accessors/gc.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/vlc-player/android/.gradle/8.9/fileChanges/last-build.bin b/modules/vlc-player/android/.gradle/8.9/fileChanges/last-build.bin
new file mode 100644
index 00000000..f76dd238
Binary files /dev/null and b/modules/vlc-player/android/.gradle/8.9/fileChanges/last-build.bin differ
diff --git a/modules/vlc-player/android/.gradle/8.9/fileHashes/fileHashes.lock b/modules/vlc-player/android/.gradle/8.9/fileHashes/fileHashes.lock
new file mode 100644
index 00000000..5743f6c9
Binary files /dev/null and b/modules/vlc-player/android/.gradle/8.9/fileHashes/fileHashes.lock differ
diff --git a/modules/vlc-player/android/.gradle/8.9/gc.properties b/modules/vlc-player/android/.gradle/8.9/gc.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/vlc-player/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/modules/vlc-player/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 00000000..964eb6bf
Binary files /dev/null and b/modules/vlc-player/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/modules/vlc-player/android/.gradle/buildOutputCleanup/cache.properties b/modules/vlc-player/android/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 00000000..c062e482
--- /dev/null
+++ b/modules/vlc-player/android/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Sun Nov 17 18:25:45 AEDT 2024
+gradle.version=8.9
diff --git a/modules/vlc-player/android/.gradle/vcs-1/gc.properties b/modules/vlc-player/android/.gradle/vcs-1/gc.properties
new file mode 100644
index 00000000..e69de29b
diff --git a/modules/vlc-player/android/build.gradle b/modules/vlc-player/android/build.gradle
new file mode 100644
index 00000000..e00e0eb2
--- /dev/null
+++ b/modules/vlc-player/android/build.gradle
@@ -0,0 +1,70 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+
+group = 'expo.modules.vlcplayer'
+version = '0.6.0'
+
+def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+apply from: expoModulesCorePlugin
+applyKotlinExpoModulesCorePlugin()
+useCoreDependencies()
+useExpoPublishing()
+
+// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
+// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
+// Most of the time, you may like to manage the Android SDK versions yourself.
+def useManagedAndroidSdkVersions = false
+if (useManagedAndroidSdkVersions) {
+ useDefaultAndroidSdkVersions()
+} else {
+ buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:7.1.3"
+ }
+ }
+ project.android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 34)
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 21)
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
+ }
+ }
+}
+
+dependencies {
+ implementation 'org.videolan.android:libvlc-all:3.6.0-eap12'
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:1.5.31"
+}
+
+android {
+ namespace "expo.modules.vlcplayer"
+ compileSdkVersion 34
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 34
+ versionCode 1
+ versionName "0.6.0"
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
+
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+ kotlinOptions {
+ freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
+ jvmTarget = "17"
+ }
+}
\ No newline at end of file
diff --git a/modules/vlc-player/android/src/main/AndroidManifest.xml b/modules/vlc-player/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..bdae66c8
--- /dev/null
+++ b/modules/vlc-player/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt
new file mode 100644
index 00000000..070e13a8
--- /dev/null
+++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerModule.kt
@@ -0,0 +1,69 @@
+package expo.modules.vlcplayer
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class VlcPlayerModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("VlcPlayer")
+
+ View(VlcPlayerView::class) {
+ Prop("source") { view: VlcPlayerView, source: Map ->
+ view.setSource(source)
+ }
+
+ Prop("paused") { view: VlcPlayerView, paused: Boolean ->
+ if (paused) {
+ view.pause()
+ } else {
+ view.play()
+ }
+ }
+
+ Events(
+ "onPlaybackStateChanged",
+ "onVideoStateChange",
+ "onVideoLoadStart",
+ "onVideoLoadEnd",
+ "onVideoProgress",
+ "onVideoError"
+ )
+
+ AsyncFunction("play") { view: VlcPlayerView ->
+ view.play()
+ }
+
+ AsyncFunction("pause") { view: VlcPlayerView ->
+ view.pause()
+ }
+
+ AsyncFunction("stop") { view: VlcPlayerView ->
+ view.stop()
+ }
+
+ AsyncFunction("seekTo") { view: VlcPlayerView, time: Int ->
+ view.seekTo(time)
+ }
+
+ AsyncFunction("setAudioTrack") { view: VlcPlayerView, trackIndex: Int ->
+ view.setAudioTrack(trackIndex)
+ }
+
+ AsyncFunction("getAudioTracks") { view: VlcPlayerView ->
+ view.getAudioTracks()
+ }
+
+ AsyncFunction("setSubtitleTrack") { view: VlcPlayerView, trackIndex: Int ->
+ view.setSubtitleTrack(trackIndex)
+ }
+
+ AsyncFunction("getSubtitleTracks") { view: VlcPlayerView ->
+ view.getSubtitleTracks()
+ }
+
+ AsyncFunction("setSubtitleURL") { view: VlcPlayerView, url: String, name: String ->
+ view.setSubtitleURL(url, name)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt
new file mode 100644
index 00000000..03245207
--- /dev/null
+++ b/modules/vlc-player/android/src/main/java/expo/modules/vlcplayer/VlcPlayerView.kt
@@ -0,0 +1,263 @@
+package expo.modules.vlcplayer
+
+import android.content.Context
+import android.util.Log
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.lifecycle.LifecycleObserver
+import android.net.Uri
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ExpoView
+import expo.modules.kotlin.viewevent.EventDispatcher
+import org.videolan.libvlc.LibVLC
+import org.videolan.libvlc.Media
+import org.videolan.libvlc.interfaces.IMedia
+import org.videolan.libvlc.MediaPlayer
+import org.videolan.libvlc.util.VLCVideoLayout
+
+class VlcPlayerView(context: Context, appContext: AppContext) : ExpoView(context, appContext), LifecycleObserver, MediaPlayer.EventListener {
+
+ private var libVLC: LibVLC? = null
+ private var mediaPlayer: MediaPlayer? = null
+ private lateinit var videoLayout: VLCVideoLayout
+ private var isPaused: Boolean = false
+ private var lastReportedState: Int? = null
+ private var lastReportedIsPlaying: Boolean? = null
+ private var media : Media? = null
+
+ private val onVideoProgress by EventDispatcher()
+ private val onVideoStateChange by EventDispatcher()
+ private val onVideoLoadEnd by EventDispatcher()
+
+ private var startPosition: Int? = 0
+ private var isMediaReady: Boolean = false
+ private var externalTrack: Map? = null
+
+ init {
+ setupView()
+ }
+
+ private fun setupView() {
+ Log.d("VlcPlayerView", "Setting up view")
+ setBackgroundColor(android.graphics.Color.WHITE)
+ videoLayout = VLCVideoLayout(context).apply {
+ layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
+ }
+ addView(videoLayout)
+ Log.d("VlcPlayerView", "View setup complete")
+ }
+
+ fun setSource(source: Map) {
+ val mediaOptions = source["mediaOptions"] as? Map ?: emptyMap()
+ val autoplay = source["autoplay"] as? Boolean ?: false
+ val isNetwork = source["isNetwork"] as? Boolean ?: false
+ externalTrack = source["externalTrack"] as? Map
+ startPosition = (source["startPosition"] as? Double)?.toInt() ?: 0
+
+ val initOptions = source["initOptions"] as? MutableList ?: mutableListOf()
+ initOptions.add("--start-time=$startPosition")
+
+
+ val uri = source["uri"] as? String
+
+ // Handle video load start event
+ // onVideoLoadStart?.invoke(mapOf("target" to reactTag ?: "null"))
+
+ libVLC = LibVLC(context, initOptions)
+ mediaPlayer = MediaPlayer(libVLC)
+ mediaPlayer?.attachViews(videoLayout, null, false, false)
+ mediaPlayer?.setEventListener(this)
+
+ Log.d("VlcPlayerView", "Loading network file: $uri")
+ media = Media(libVLC, Uri.parse(uri))
+ mediaPlayer?.media = media
+
+
+ Log.d("VlcPlayerView", "Debug: Media options: $mediaOptions")
+ // media.addOptions(mediaOptions)
+
+ // Apply subtitle options
+ // val subtitleTrackIndex = source["subtitleTrackIndex"] as? Int ?: -1
+ // Log.d("VlcPlayerView", "Debug: Subtitle track index from source: $subtitleTrackIndex")
+
+ // if (subtitleTrackIndex >= -1) {
+ // setSubtitleTrack(subtitleTrackIndex)
+ // Log.d("VlcPlayerView", "Debug: Set subtitle track to index: $subtitleTrackIndex")
+ // } else {
+ // Log.d("VlcPlayerView", "Debug: Subtitle track index is less than -1, not setting")
+ // }
+
+
+ if (autoplay) {
+ Log.d("VlcPlayerView", "Playing...")
+ play()
+ }
+ }
+
+ fun play() {
+ mediaPlayer?.play()
+ isPaused = false
+ }
+
+ fun pause() {
+ mediaPlayer?.pause()
+ isPaused = true
+ }
+
+ fun stop() {
+ mediaPlayer?.stop()
+ }
+
+ fun seekTo(time: Int) {
+ mediaPlayer?.let { player ->
+ val wasPlaying = player.isPlaying
+ if (wasPlaying) {
+ player.pause()
+ }
+
+ val duration = player.length.toInt()
+ val seekTime = if (time > duration) duration - 1000 else time
+ player.time = seekTime.toLong()
+
+ if (wasPlaying) {
+ player.play()
+ }
+ }
+ }
+
+ fun setAudioTrack(trackIndex: Int) {
+ mediaPlayer?.setAudioTrack(trackIndex)
+ }
+
+ fun getAudioTracks(): List